Skip to content

Commit

Permalink
commit pexels-go client
Browse files Browse the repository at this point in the history
  • Loading branch information
kosa3 committed Sep 23, 2021
0 parents commit cde90af
Show file tree
Hide file tree
Showing 19 changed files with 780 additions and 0 deletions.
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Pexels API Client for Go

`kosa3/pexels-go` is [Pexels API](https://www.pexels.com/api/documentation/) Client for Go.
[Pexels](https://www.pexels.com/) is the best free stock photos & videos shared by talented creators.


## Install

```
$ go get -u github.com/kosa3/pexels-go
```

## Examples

See [example](./example) directory.

```go
func main() {
cli := pixels.NewClient(os.Args[1])
ctx := context.Background()
ps, err := cli.PhotoService.Search(ctx, &pixels.PhotoParams{
Query: "people",
Page: 2,
})
if err != nil {
log.Fatal(err)
}

fmt.Println("Source Medium URL:", ps.Photos[0].Src.Medium)
fmt.Println("RateLimit:", cli.LastRateLimit.Limit)
}
```

## Supported API

### Photo

| Endpoint | HTTP Method |
|-----------------------------------------|:-----------:|
|/v1/search | GET |
|/v1/curated | GET |
|/v1/photos/{id} | GET |



### Video

| Endpoint | HTTP Method |
|-----------------------------------------|:-----------:|
|/videos/search | GET |
|/videos/popular | GET |
|/videos/videos/{id} | GET |

### Collection

| Endpoint | HTTP Method |
|-----------------------------------------|:-----------:|
|/v1/collections/featured | GET |
|/v1/collections | GET |
|/v1/collections/{id} | GET |
179 changes: 179 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package pixels

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"reflect"
"regexp"
"strconv"
"strings"
"time"
)

const (
baseURL = "https://api.pexels.com/v1"
videoBaseURL = "https://api.pexels.com"
)

type RateLimit struct {
Limit int64
Remaining int64
Reset time.Time
}

type Client struct {
PhotoService PhotoService
VideoService VideoService
CollectionService CollectionService

HTTPClient *http.Client
AccessToken string
BaseURL string
LastRateLimit *RateLimit
}

func NewClient(accessToken string) *Client {
var cli Client
cli.PhotoService = &photoService{cli: &cli}
cli.VideoService = &videoService{cli: &cli}
cli.CollectionService = &collectionService{cli: &cli}

cli.AccessToken = accessToken
cli.BaseURL = baseURL

return &cli
}

func (cli *Client) httpClient() *http.Client {
if cli.HTTPClient != nil {
return cli.HTTPClient
}

return http.DefaultClient
}

func (cli *Client) do(ctx context.Context, req *http.Request) (*http.Response, error) {
req = req.WithContext(ctx)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cli.AccessToken))
req.Header.Set("Content-Type", "application/json")
//req.Header.Set("User-Agent", "Pexels/Go")
return cli.httpClient().Do(req)
}

func (cli *Client) get(ctx context.Context, path string, params url.Values, v interface{}) error {
r := regexp.MustCompile("^videos")
if r.MatchString(path) {
cli.BaseURL = videoBaseURL
}

reqURL := cli.BaseURL + "/" + path
if len(params) > 0 {
reqURL += "?" + params.Encode()
}
fmt.Println(reqURL)

req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return fmt.Errorf("cannot create HTTP request: %w", err)
}

resp, err := cli.do(ctx, req)
if err != nil {
return err
}

defer resp.Body.Close()

if !(resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices) {
return cli.error(resp.StatusCode, resp.Body)
}

rl, err := RateLimitFromHeader(resp.Header)
if err != nil {
return err
}

cli.LastRateLimit = rl

if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
return fmt.Errorf("cannot parse HTTP body: %w", err)
}

return nil
}

func RateLimitFromHeader(h http.Header) (*RateLimit, error) {
ls := h.Get("X-Ratelimit-Limit")
if ls == "" {
return nil, errors.New("cannot get X-Ratelimit-Limit from header")
}

l, err := strconv.ParseInt(ls, 10, 64)
if err != nil {
return nil, fmt.Errorf("X-Ratelimit-Limit is invalid value: %w", err)
}

rs := h.Get("X-Ratelimit-Remaining")
if rs == "" {
return nil, errors.New("cannot get X-Ratelimit-Remaining from header")
}

r, err := strconv.ParseInt(rs, 10, 64)
if err != nil {
return nil, fmt.Errorf("X-Rate-Limit-Remaining is invalid value: %w", err)
}

ts := h.Get("X-Ratelimit-Reset")
if ts == "" {
return nil, errors.New("cannot get X-Ratelimit-Reset from header")
}

t, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return nil, fmt.Errorf("X-Ratelimit-Reset is invalid value: %w", err)
}

return &RateLimit{
Limit: l,
Remaining: r,
Reset: time.Unix(t, 0),
}, nil
}

func (cli *Client) error(statusCode int, body io.Reader) error {
var aerr APIError
if err := json.NewDecoder(body).Decode(&aerr); err != nil {
return &APIError{HTTPStatus: statusCode}
}
aerr.HTTPStatus = statusCode
return &aerr
}

func StructToMap(i interface{}) (values url.Values) {
values = url.Values{}
if reflect.ValueOf(i).IsNil() {
return
}
iVal := reflect.ValueOf(i).Elem()
typ := iVal.Type()
for i := 0; i < iVal.NumField(); i++ {
if !iVal.Field(i).IsZero() {
values.Set(toSnakeCase(typ.Field(i).Name), fmt.Sprint(iVal.Field(i)))
}
}
return
}

func toSnakeCase(str string) string {
var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")

snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
return strings.ToLower(snake)
}
56 changes: 56 additions & 0 deletions collection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package pixels

type CollectionParams struct {
Type string
PageParams
}

type CollectionResponse struct {
TotalResults int `json:"total_results"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Collections []*Collection `json:"collections"`
NextPage string `json:"next_page"`
PrevPage string `json:"prev_page"`
}

type CollectionMediaResponse struct {
ID string `json:"id"`
Media []Media `json:"media"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalResults int `json:"total_results"`
NextPage string `json:"next_page"`
PrevPage string `json:"prev_page"`
}

type Media struct {
Type string `json:"type"`
ID int `json:"id"`
Width int `json:"width"`
Height int `json:"height"`
URL string `json:"url"`
Photographer string `json:"photographer,omitempty"`
PhotographerURL string `json:"photographer_url,omitempty"`
PhotographerID int `json:"photographer_id,omitempty"`
AvgColor string `json:"avg_color"`
Src Source `json:"src,omitempty"`
Liked bool `json:"liked,omitempty"`
Duration int `json:"duration,omitempty"`
FullRes interface{} `json:"full_res,omitempty"`
Tags []interface{} `json:"tags,omitempty"`
Image string `json:"image,omitempty"`
User User `json:"user,omitempty"`
VideoFiles []VideoFile `json:"video_files,omitempty"`
VideoPictures []VideoPicture `json:"video_pictures,omitempty"`
}

type Collection struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Private bool `json:"private"`
MediaCount int `json:"media_count"`
PhotosCount int `json:"photos_count"`
VideosCount int `json:"videos_count"`
}
43 changes: 43 additions & 0 deletions collection_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package pixels

import (
"context"
"fmt"
)

type CollectionService interface {
Featured(ctx context.Context, params *PageParams) (*CollectionResponse, error)
Get(ctx context.Context, params *PageParams) (*CollectionResponse, error)
Find(ctx context.Context, collectionId string, params *CollectionParams) (*CollectionMediaResponse, error)
}

type collectionService struct {
cli *Client
}

func (s *collectionService) Featured(ctx context.Context, params *PageParams) (*CollectionResponse, error) {
var sr CollectionResponse
if err := s.cli.get(ctx, "collections/featured", StructToMap(params), &sr); err != nil {
return nil, fmt.Errorf("GET featured failed: %w", err)
}

return &sr, nil
}

func (s *collectionService) Get(ctx context.Context, params *PageParams) (*CollectionResponse, error) {
var cr CollectionResponse
if err := s.cli.get(ctx, "collections", StructToMap(params), &cr); err != nil {
return nil, fmt.Errorf("GET get failed: %w", err)
}

return &cr, nil
}

func (s *collectionService) Find(ctx context.Context, collectionId string, params *CollectionParams) (*CollectionMediaResponse, error) {
var cmr CollectionMediaResponse
if err := s.cli.get(ctx, "collections/"+collectionId, StructToMap(params), &cmr); err != nil {
return nil, fmt.Errorf("GET find failed: %w", err)
}

return &cmr, nil
}
20 changes: 20 additions & 0 deletions error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package pixels

import (
"fmt"
)

type APIError struct {
HTTPStatus int `json:"-"`
Code int `json:"code"`
Message string `json:"message"`
}

var _ error = (*APIError)(nil)

func (err *APIError) Error() string {
if err.Message == "" {
return fmt.Sprintf("request failed with status code %d", err.HTTPStatus)
}
return fmt.Sprintf("StatusCode: %d, Code: %d, Message: %s", err.HTTPStatus, err.Code, err.Message)
}
23 changes: 23 additions & 0 deletions example/collection/featured/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package main

import (
"context"
"fmt"
pixels "github.com/kosa3/pexels-go"
"log"
"os"
)

func main() {
cli := pixels.NewClient(os.Args[1])
ctx := context.Background()
cs, err := cli.CollectionService.Featured(ctx, &pixels.PageParams{
Page: 1,
})
if err != nil {
log.Fatal(err)
}

fmt.Println("Collection Title:", cs.Collections[0].Title)
fmt.Println("RateLimit:", cli.LastRateLimit.Limit)
}
Loading

0 comments on commit cde90af

Please sign in to comment.