├── LICENSE ├── README.md ├── cache.go ├── client.go ├── client_test.go ├── content.go ├── file.go ├── multipart.go ├── search.go ├── test └── util_test.go ├── util.go └── values.go /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2016 Boss Sauce Creative, LLC. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Work in progress 2 | 3 | # Ponzu HTTP Client - Go 4 | 5 | The Go client can be used from within any Go program to interact with a Ponzu CMS. 6 | 7 | Currently, the Go client has built-in support for: 8 | 9 | ### Content API 10 | Fetching content from Ponzu using: 11 | - [Content](https://godoc.org/github.com/ponzu-cms/go-client#Client.Content): get single content item from Ponzu by Type and ID 12 | - [Contents](https://godoc.org/github.com/ponzu-cms/go-client#Client.Contents): get multiple content items as per QueryOptions from Ponzu 13 | - [ContentBySlug](https://godoc.org/github.com/ponzu-cms/go-client#Client.ContentBySlug): get content identified only by its slug from Ponzu 14 | - [Reference](https://godoc.org/github.com/ponzu-cms/go-client#Client.Reference): get content behind a reference URI 15 | 16 | Creating content in Ponzu using: 17 | - [Create](https://godoc.org/github.com/ponzu-cms/go-client#Client.Create): insert content (if allowed) into Ponzu CMS, handles multipart/form-data 18 | encoding and file uploads 19 | 20 | Updating content in Ponzu using: 21 | - [Update](https://godoc.org/github.com/ponzu-cms/go-client#Client.Update): change content (if allowed) in Ponzu CMS, handles multipart/form-data 22 | encoding and file uploads 23 | 24 | Deleting content in Ponzu using: 25 | - [Delete](https://godoc.org/github.com/ponzu-cms/go-client#Client.Delete): remove content (if allowed) in Ponzu CMS, handles multipart/form-data 26 | encoding 27 | 28 | ### Search API 29 | If your content types are indexed, the Go client can handle search requests using: 30 | - [Search](https://godoc.org/github.com/ponzu-cms/go-client#Client.Search): find content items matching a query for a specific content type 31 | 32 | ### File Metadata API 33 | All files uploaded to a Ponzu CMS can be inspected if the file slug is known, using: 34 | - [FileBySlug](https://godoc.org/github.com/ponzu-cms/go-client#Client.FileBySlug): see metadata associated with a file, including file size, content type, 35 | and upload date 36 | 37 | ### Usage 38 | ```go 39 | package main 40 | 41 | import ( 42 | "fmt" 43 | "net/url" 44 | "net/http" 45 | "github.com/ponzu-cms/go-client" 46 | ) 47 | 48 | func main() { 49 | // configure the http client 50 | cfg := &client.Config{ 51 | Host: "http://localhost:8080", 52 | DisableCache: false, // defaults to false, here for documentation 53 | Header: make(http.Header), 54 | } 55 | 56 | // add custom header(s) if needed: 57 | cfg.Header.Set("Authorization", "Bearer $ACCESS_TOKEN") 58 | cfg.Header.Set("X-Client", "MyGoApp v0.9") 59 | 60 | ponzu := client.New(cfg) 61 | 62 | 63 | //------------------------------------------------------------------ 64 | // GET content (single item) 65 | //------------------------------------------------------------------ 66 | 67 | // fetch single Content item of type Blog by ID 1 68 | resp, err := ponzu.Content("Blog", 1) 69 | if err != nil { 70 | fmt.Println("Blog:1 error:", err) 71 | return 72 | } 73 | 74 | fmt.Println(resp.Data[0]["title"]) 75 | fmt.Println(fieldName) 76 | for fieldName , _ := range resp.Data[0] { 77 | fmt.Println(fieldName) 78 | } 79 | 80 | 81 | //------------------------------------------------------------------ 82 | // GET contents (multiple items) 83 | //------------------------------------------------------------------ 84 | 85 | // fetch multiple Content items of type Blog, using the default QueryOptions 86 | // Count: 10 87 | // Offset: 0 88 | // Order: DESC 89 | resp, err = ponzu.Contents("Blog", nil) 90 | if err != nil { 91 | fmt.Println("Blog:multi error:", err) 92 | return 93 | } 94 | 95 | for i := 0; i < len(resp.Data); i++ { 96 | fmt.Println(resp.Data[i]["title"]) 97 | } 98 | 99 | 100 | 101 | //------------------------------------------------------------------ 102 | // SEARCH content 103 | //------------------------------------------------------------------ 104 | 105 | // fetch the search results for a query "Steve" from Content items of type Blog 106 | resp, err = ponzu.Search("Blog", "Steve") 107 | if err != nil { 108 | fmt.Println("Blog:search error:", err) 109 | return 110 | } 111 | 112 | fmt.Println(resp.Data[0]["title"]) 113 | fmt.Println(resp.Data[1]["title"]) 114 | 115 | 116 | 117 | //------------------------------------------------------------------ 118 | // GET File metadata (single item) 119 | //------------------------------------------------------------------ 120 | 121 | // fetch file metadata for uploaded file with slug "mudcracks-mars.jpg" (slug is normalized filename) 122 | resp, err = ponzu.FileBySlug("mudcracks-mars.jpg") 123 | if err != nil { 124 | fmt.Println("File:slug error:", err) 125 | return 126 | } 127 | 128 | fmt.Println(resp.Data[0]["name"]) 129 | fmt.Println(resp.Data[0]["content_type"]) 130 | fmt.Println(resp.Data[0]["content_length"]) 131 | 132 | 133 | 134 | //------------------------------------------------------------------ 135 | // CREATE content (single item) 136 | //------------------------------------------------------------------ 137 | 138 | // create Content item of type Blog with data 139 | data := client.NewValues() 140 | data.Set("title", "Added via API") 141 | data.Set("body", "

Here's some HTML for you...

") 142 | data.Set("author", "Steve") 143 | 144 | // or, instead of making client.Values and setting key/values use helper func: 145 | blog := &content.Blog{ 146 | Title: "Added via API client", 147 | Body: "

Here's some HTML for you...

", 148 | Author: "Steve", 149 | } 150 | data, err := client.ToValues(blog) 151 | 152 | 153 | // nil indicates no data params are filepaths, 154 | // otherwise would be a []string of key names that are filepaths (docs coming) 155 | resp, err = ponzu.Create("Blog", data, nil) 156 | if err != nil { 157 | fmt.Println("Create:Blog error:", err) 158 | return 159 | } 160 | 161 | fmt.Println(resp.Data[0]["status"], resp.Data[0]["id"]) 162 | id := int(resp.Data[0]["id"].(float64)) 163 | 164 | 165 | 166 | //------------------------------------------------------------------ 167 | // UPDATE content (single item) 168 | //------------------------------------------------------------------ 169 | 170 | // update Content item of type Blog and ID {id} with data 171 | data = client.NewValues() 172 | data.Set("title", "Added then updated via API") 173 | data.Set("author", "API Steve") 174 | 175 | resp, err = ponzu.Update("Blog", id, data, nil) 176 | if err != nil { 177 | fmt.Println("Create:Blog error:", err) 178 | return 179 | } 180 | 181 | resp, err = ponzu.Search("Blog", `"API Steve"`) 182 | if err != nil { 183 | fmt.Println("Blog:search error:", err) 184 | return 185 | } 186 | 187 | fmt.Println(resp.Data[0]["title"]) 188 | 189 | 190 | 191 | //------------------------------------------------------------------ 192 | // DELETE content (single item) 193 | //------------------------------------------------------------------ 194 | 195 | // delete Content item of type Blog with ID {id} 196 | resp, err = ponzu.Delete("Blog", id) 197 | if err != nil { 198 | fmt.Println("Delete:Blog:#id error:", err, id) 199 | return 200 | } 201 | 202 | fmt.Println(resp.Data[0]["status"], resp.Data[0]["id"]) 203 | } 204 | 205 | ``` 206 | 207 | Alternatively, the `resp` return value (which is an `*APIResponse` type) contains the response body's original 208 | `[]byte` as `resp.JSON`, which you can use to unmarshal to content structs from 209 | your Ponzu's `content` package. 210 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type entity struct { 12 | evictAfter time.Duration 13 | added time.Time 14 | response *APIResponse 15 | } 16 | 17 | type Cache struct { 18 | mu sync.Mutex 19 | Data map[string]entity 20 | } 21 | 22 | // func NewCache(evictAfter time.Duration) *Cache { 23 | func NewCache() *Cache { 24 | c := &Cache{ 25 | mu: sync.Mutex{}, 26 | Data: make(map[string]entity), 27 | } 28 | 29 | // TODO: add Data eviction after certain time has passed regardless of 30 | // entity maxAge attribute 31 | 32 | return c 33 | } 34 | 35 | func (c *Cache) Check(endpoint string) (bool, *APIResponse) { 36 | c.mu.Lock() 37 | defer c.mu.Unlock() 38 | 39 | hit, ok := c.Data[endpoint] 40 | if !ok { 41 | return false, &APIResponse{} 42 | } 43 | 44 | if time.Now().After(hit.added.Add(hit.evictAfter)) { 45 | delete(c.Data, endpoint) 46 | return false, &APIResponse{} 47 | } 48 | 49 | return ok, hit.response 50 | } 51 | 52 | func (c *Cache) Add(endpoint string, resp *APIResponse) error { 53 | c.mu.Lock() 54 | defer c.mu.Unlock() 55 | 56 | maxAge, err := parseMaxAgeHeader(resp) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | c.Data[endpoint] = entity{ 62 | evictAfter: time.Duration(maxAge), 63 | added: time.Now(), 64 | response: resp, 65 | } 66 | 67 | return nil 68 | } 69 | 70 | func parseMaxAgeHeader(resp *APIResponse) (time.Duration, error) { 71 | cc := resp.Response.Header.Get("Cache-Control") 72 | kv := strings.Split(cc, ", ") 73 | if len(kv) < 2 { 74 | return 0, fmt.Errorf("malformed '%s' header", "Cache-Control") 75 | } 76 | 77 | ma := kv[0] 78 | parts := strings.Split(ma, "=") 79 | if len(parts) < 2 { 80 | return 0, fmt.Errorf("malformed '%s' key", "max-age") 81 | } 82 | 83 | sec, err := strconv.Atoi(parts[1]) 84 | if err != nil { 85 | return time.Duration(0), err 86 | } 87 | 88 | return time.Second * time.Duration(sec), nil 89 | } 90 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | type Client struct { 11 | http.Client 12 | Conf *Config 13 | Cache *Cache 14 | } 15 | 16 | type Config struct { 17 | Host string `json:"host"` 18 | DisableCache bool `json:"disable_cache"` 19 | Header http.Header 20 | } 21 | 22 | type APIResponse struct { 23 | Response *http.Response 24 | 25 | JSON []byte 26 | Data []map[string]interface{} 27 | } 28 | 29 | // QueryOptions holds options for a query 30 | type QueryOptions struct { 31 | Count int 32 | Offset int 33 | Order string 34 | } 35 | 36 | func New(cfg *Config) *Client { 37 | c := &Client{ 38 | Conf: cfg, 39 | } 40 | 41 | if cfg.DisableCache { 42 | return c 43 | } 44 | 45 | c.Cache = NewCache() 46 | return c 47 | } 48 | 49 | func (c *Client) CacheEnabled() bool { 50 | return !c.Conf.DisableCache 51 | } 52 | 53 | func (a *APIResponse) process() error { 54 | jsn, err := ioutil.ReadAll(a.Response.Body) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | if len(jsn) == 0 { 60 | return fmt.Errorf("%s", a.Response.Status) 61 | } 62 | 63 | data := make(map[string][]map[string]interface{}) 64 | err = json.Unmarshal(jsn, &data) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | a.JSON = jsn 70 | a.Data = data["data"] 71 | 72 | return nil 73 | } 74 | 75 | func setDefaultOpts(opts *QueryOptions) *QueryOptions { 76 | if opts == nil { 77 | opts = &QueryOptions{ 78 | Count: 10, 79 | Order: "DESC", 80 | Offset: 0, 81 | } 82 | } 83 | 84 | if opts.Count == 0 { 85 | opts.Count = 10 86 | } 87 | if opts.Order == "" { 88 | opts.Order = "DESC" 89 | } 90 | 91 | return opts 92 | } 93 | 94 | func mergeHeader(req *http.Request, header http.Header) *http.Request { 95 | for k, v := range header { 96 | for i := range v { 97 | req.Header.Add(k, v[i]) 98 | } 99 | } 100 | 101 | return req 102 | } 103 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | ) 7 | 8 | func TestMergeHeader(t *testing.T) { 9 | cases := map[string]string{ 10 | "Content-Type": "text/plain", 11 | "X-Test-1": "Value 1", 12 | } 13 | 14 | req, err := http.NewRequest("GET", "http://localhost:8080", nil) 15 | if err != nil { 16 | t.Errorf("Error creating request: %v", err) 17 | } 18 | 19 | // add some default to the original request header 20 | req.Header.Set("Content-Type", "text/plain") 21 | 22 | header := http.Header{} 23 | header.Set("X-Test-1", "Value 1") 24 | 25 | req = mergeHeader(req, header) 26 | 27 | for test, exp := range cases { 28 | res := req.Header.Get(test) 29 | if res != exp { 30 | t.Errorf("expected %s, got %s", exp, res) 31 | } 32 | } 33 | 34 | // Test multipart requests 35 | 36 | req, err = multipartForm("http://localhost:8080", NewValues(), nil) 37 | if err != nil { 38 | t.Errorf("Error creating multipart request: %v", err) 39 | } 40 | 41 | cases = map[string]string{ 42 | "Content-Type": req.Header.Get("Content-Type"), // will have header from multipartForm 43 | "X-Test-1": "Value 1", 44 | } 45 | 46 | req = mergeHeader(req, header) 47 | 48 | for test, exp := range cases { 49 | res := req.Header.Get(test) 50 | if res != exp { 51 | t.Errorf("expected %s, got %s", exp, res) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /content.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | // Content makes a GET request to return the Ponzu Content API response for the 9 | // enpoint: `/api/content?type=&id=` 10 | func (c *Client) Content(contentType string, id int) (*APIResponse, error) { 11 | endpoint := fmt.Sprintf( 12 | "%s/api/content?type=%s&id=%d", 13 | c.Conf.Host, contentType, id, 14 | ) 15 | 16 | if c.CacheEnabled() { 17 | ok, resp := c.Cache.Check(endpoint) 18 | if ok { 19 | return resp, nil 20 | } 21 | } 22 | 23 | req, err := http.NewRequest(http.MethodGet, endpoint, nil) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | req = mergeHeader(req, c.Conf.Header) 29 | 30 | w, err := c.Do(req) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | resp := &APIResponse{ 36 | Response: w, 37 | } 38 | 39 | err = resp.process() 40 | if err != nil { 41 | return resp, err 42 | } 43 | 44 | if c.CacheEnabled() { 45 | err := c.Cache.Add(endpoint, resp) 46 | return resp, err 47 | } 48 | 49 | return resp, nil 50 | } 51 | 52 | // ContentBySlug makes a GET request to return the Ponzu Content API response for the 53 | // enpoint: `/api/content?slug=` 54 | func (c *Client) ContentBySlug(slug string) (*APIResponse, error) { 55 | endpoint := fmt.Sprintf( 56 | "%s/api/content?slug=%s", 57 | c.Conf.Host, slug, 58 | ) 59 | 60 | if c.CacheEnabled() { 61 | ok, resp := c.Cache.Check(endpoint) 62 | if ok { 63 | return resp, nil 64 | } 65 | } 66 | 67 | req, err := http.NewRequest(http.MethodGet, endpoint, nil) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | req = mergeHeader(req, c.Conf.Header) 73 | 74 | w, err := c.Do(req) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | resp := &APIResponse{ 80 | Response: w, 81 | } 82 | 83 | err = resp.process() 84 | if err != nil { 85 | return resp, err 86 | } 87 | 88 | if c.CacheEnabled() { 89 | err := c.Cache.Add(endpoint, resp) 90 | return resp, err 91 | } 92 | 93 | return resp, nil 94 | } 95 | 96 | // Contents makes a GET request to return the Ponzu Content API response for the 97 | // enpoint: `/api/contents?type=` with query options: 98 | // Count 99 | // Offset 100 | // Order 101 | func (c *Client) Contents(contentType string, opts *QueryOptions) (*APIResponse, error) { 102 | opts = setDefaultOpts(opts) 103 | 104 | endpoint := fmt.Sprintf( 105 | "%s/api/contents?type=%s&count=%d&offest=%d&order=%s", 106 | c.Conf.Host, contentType, opts.Count, opts.Offset, opts.Order, 107 | ) 108 | 109 | if c.CacheEnabled() { 110 | ok, resp := c.Cache.Check(endpoint) 111 | if ok { 112 | return resp, nil 113 | } 114 | } 115 | 116 | req, err := http.NewRequest(http.MethodGet, endpoint, nil) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | req = mergeHeader(req, c.Conf.Header) 122 | 123 | w, err := c.Do(req) 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | resp := &APIResponse{ 129 | Response: w, 130 | } 131 | 132 | err = resp.process() 133 | if err != nil { 134 | return resp, err 135 | } 136 | 137 | if c.CacheEnabled() { 138 | err := c.Cache.Add(endpoint, resp) 139 | return resp, err 140 | } 141 | 142 | return resp, nil 143 | } 144 | 145 | // Create makes a POST request containing a multipart/form-data body with the 146 | // contents of a content item to be created and stored in Ponzu. Note: the fileKeys 147 | // []string argument should contain the field/key names of the item's uploads. 148 | // 149 | // The *APIResponse will indicate whether the request failed or succeeded based 150 | // on the contents of its Data or JSON fields, or by checking the Status of it's 151 | // original http.Response. Callers should expect failures to occur when a Content 152 | // type does not implement the api.Createable interface 153 | func (c *Client) Create(contentType string, data *Values, fileKeys []string) (*APIResponse, error) { 154 | endpoint := fmt.Sprintf( 155 | "%s/api/content/create?type=%s", 156 | c.Conf.Host, contentType, 157 | ) 158 | 159 | req, err := multipartForm(endpoint, data, fileKeys) 160 | if err != nil { 161 | return nil, err 162 | } 163 | 164 | req = mergeHeader(req, c.Conf.Header) 165 | 166 | w, err := c.Do(req) 167 | if err != nil { 168 | return nil, err 169 | } 170 | 171 | resp := &APIResponse{ 172 | Response: w, 173 | } 174 | 175 | err = resp.process() 176 | if err != nil { 177 | return nil, err 178 | } 179 | 180 | return resp, nil 181 | } 182 | 183 | // Update makes a POST request containing a multipart/form-data body with the 184 | // contents of a content item to be updated and stored in Ponzu. Note: the fileKeys 185 | // []string argument should contain the field/key names of the item's uploads 186 | // 187 | // The *APIResponse will indicate whether the request failed or succeeded based 188 | // on the contents of its Data or JSON fields, or by checking the Status of it's 189 | // original http.Response. Callers should expect failures to occur when a Content 190 | // type does not implement the api.Updateable interface 191 | func (c *Client) Update(contentType string, id int, data *Values, fileKeys []string) (*APIResponse, error) { 192 | endpoint := fmt.Sprintf( 193 | "%s/api/content/update?type=%s&id=%d", 194 | c.Conf.Host, contentType, id, 195 | ) 196 | 197 | req, err := multipartForm(endpoint, data, fileKeys) 198 | if err != nil { 199 | return nil, err 200 | } 201 | 202 | req = mergeHeader(req, c.Conf.Header) 203 | 204 | w, err := c.Do(req) 205 | if err != nil { 206 | return nil, err 207 | } 208 | 209 | resp := &APIResponse{ 210 | Response: w, 211 | } 212 | 213 | err = resp.process() 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | return resp, nil 219 | } 220 | 221 | // Delete makes a POST request to the proper endpoint and with the required data 222 | // to remove content from Ponzu 223 | // 224 | // The *APIResponse will indicate whether the request failed or succeeded based 225 | // on the contents of its Data or JSON fields, or by checking the Status of it's 226 | // original http.Response. Callers should expect failures to occur when a Content 227 | // type does not implement the api.Deleteable interface 228 | func (c *Client) Delete(contentType string, id int) (*APIResponse, error) { 229 | endpoint := fmt.Sprintf( 230 | "%s/api/content/delete?type=%s&id=%d", 231 | c.Conf.Host, contentType, id, 232 | ) 233 | 234 | req, err := multipartForm(endpoint, NewValues(), nil) 235 | if err != nil { 236 | return nil, err 237 | } 238 | 239 | w, err := c.Do(req) 240 | if err != nil { 241 | return nil, err 242 | } 243 | 244 | req = mergeHeader(req, c.Conf.Header) 245 | 246 | resp := &APIResponse{ 247 | Response: w, 248 | } 249 | 250 | err = resp.process() 251 | if err != nil { 252 | return nil, err 253 | } 254 | 255 | return resp, nil 256 | } 257 | 258 | // Reference is a helper method to fetch a content item that is referenced from 259 | // a parent content type 260 | func (c *Client) Reference(uri string) (*APIResponse, error) { 261 | target, err := ParseReferenceURI(uri) 262 | if err != nil { 263 | return nil, err 264 | } 265 | 266 | return c.Content(target.Type, target.ID) 267 | } 268 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "fmt" 4 | 5 | // FileBySlug makes a GET request to return the Ponzu File Metadata API 6 | // response for the enpoint: `/api/uploads?slug=` 7 | func (c *Client) FileBySlug(slug string) (*APIResponse, error) { 8 | endpoint := fmt.Sprintf( 9 | "%s/api/uploads?slug=%s", 10 | c.Conf.Host, slug, 11 | ) 12 | 13 | if c.CacheEnabled() { 14 | ok, resp := c.Cache.Check(endpoint) 15 | if ok { 16 | return resp, nil 17 | } 18 | } 19 | 20 | w, err := c.Get(endpoint) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | resp := &APIResponse{ 26 | Response: w, 27 | } 28 | 29 | err = resp.process() 30 | if err != nil { 31 | return resp, err 32 | } 33 | 34 | if c.CacheEnabled() { 35 | err := c.Cache.Add(endpoint, resp) 36 | return resp, err 37 | } 38 | 39 | return resp, err 40 | } 41 | -------------------------------------------------------------------------------- /multipart.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "mime/multipart" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | ) 14 | 15 | func multipartForm(endpoint string, params *Values, fileParams []string) (*http.Request, error) { 16 | if params == nil { 17 | return nil, errors.New("form data params must not be nil") 18 | } 19 | body := &bytes.Buffer{} 20 | writer := multipart.NewWriter(body) 21 | 22 | for name := range params.values { 23 | if keyIsFile(name, fileParams) { 24 | if len(params.values[name]) > 1 { 25 | // iterate through file paths (has multiple, like FileRepeater), 26 | // make name => name.0, name.1, ... name.N 27 | files := params.values[name] 28 | for i := range files { 29 | fieldName := fmt.Sprintf("%s.%d", name, i) 30 | err := addFileToWriter(fieldName, files[i], writer) 31 | if err != nil { 32 | return nil, err 33 | } 34 | } 35 | } else { 36 | err := addFileToWriter(name, params.values.Get(name), writer) 37 | if err != nil { 38 | return nil, err 39 | } 40 | } 41 | } else { 42 | err := writer.WriteField(name, params.values.Get(name)) 43 | if err != nil { 44 | return nil, err 45 | } 46 | } 47 | } 48 | 49 | err := writer.Close() 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | req, err := http.NewRequest(http.MethodPost, endpoint, body) 55 | req.Header.Set("Content-Type", writer.FormDataContentType()) 56 | return req, err 57 | } 58 | 59 | func keyIsFile(key string, fileKeys []string) bool { 60 | for i := range fileKeys { 61 | if key == fileKeys[i] { 62 | return true 63 | } 64 | } 65 | 66 | return false 67 | } 68 | 69 | func addFileToWriter(fieldName, filePath string, w *multipart.Writer) error { 70 | paths := strings.Split(filePath, string(filepath.Separator)) 71 | filename := paths[len(paths)-1] 72 | part, err := w.CreateFormFile(fieldName, filename) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | src, err := os.Open(filePath) 78 | if err != nil { 79 | return err 80 | } 81 | defer src.Close() 82 | 83 | _, err = io.Copy(part, src) 84 | if err != nil { 85 | return err 86 | } 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | ) 7 | 8 | // Search makes a GET request to return the Ponzu Search API response for the 9 | // enpoint: `/api/search?type=&q=` 10 | func (c *Client) Search(contentType string, q string, opts *QueryOptions) (*APIResponse, error) { 11 | opts = setDefaultOpts(opts) 12 | 13 | endpoint := fmt.Sprintf( 14 | "%s/api/search?type=%s&q=%s&count=%d&offset=%d", 15 | c.Conf.Host, contentType, url.QueryEscape(q), opts.Count, opts.Offset, 16 | ) 17 | 18 | if c.CacheEnabled() { 19 | ok, resp := c.Cache.Check(endpoint) 20 | if ok { 21 | return resp, nil 22 | } 23 | } 24 | 25 | w, err := c.Get(endpoint) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | resp := &APIResponse{ 31 | Response: w, 32 | } 33 | 34 | err = resp.process() 35 | if err != nil { 36 | return resp, err 37 | } 38 | 39 | if c.CacheEnabled() { 40 | err := c.Cache.Add(endpoint, resp) 41 | return resp, err 42 | } 43 | 44 | return resp, err 45 | } 46 | -------------------------------------------------------------------------------- /test/util_test.go: -------------------------------------------------------------------------------- 1 | package client_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | client "github.com/ponzu-cms/go-client" 8 | "github.com/ponzu-cms/ponzu/system/item" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestToValues(t *testing.T) { 13 | type ContentExample struct { 14 | item.Item 15 | 16 | Name string `json:"name"` 17 | ID int `json:"id"` 18 | Tags []string `json:"tags"` 19 | } 20 | 21 | ex := &ContentExample{ 22 | Name: "Test case name", 23 | ID: 1, 24 | Tags: []string{"first", "second", "third"}, 25 | } 26 | 27 | data, err := client.ToValues(ex) 28 | if err != nil { 29 | t.Log(err) 30 | t.Fail() 31 | } 32 | 33 | dataTags := data.Get("tags").([]string) 34 | for i, tag := range dataTags { 35 | if tag != ex.Tags[i] { 36 | t.Fatalf("%#v", data) 37 | } 38 | } 39 | 40 | assert.Equal(t, ex.Name, data.Get("name")) 41 | assert.Equal(t, fmt.Sprintf("%d", ex.ID), data.Get("id")) 42 | assert.Equal(t, ex.Tags[0], data.Get("tags.0")) 43 | assert.Equal(t, ex.Tags[1], data.Get("tags.1")) 44 | assert.Equal(t, ex.Tags[2], data.Get("tags.2")) 45 | } 46 | 47 | func TestParseReferenceURI(t *testing.T) { 48 | cases := map[string]client.Target{ 49 | "/api/content?type=Test&id=1": client.Target{Type: "Test", ID: 1}, 50 | } 51 | 52 | for in, expected := range cases { 53 | got, err := client.ParseReferenceURI(in) 54 | if err != nil { 55 | fmt.Println(err) 56 | t.Fail() 57 | } 58 | 59 | if got.ID != expected.ID { 60 | fmt.Printf("expected: %v got: %v\n", expected.ID, got.ID) 61 | t.Fail() 62 | } 63 | } 64 | } 65 | 66 | func TestParseReferenceURIErrors(t *testing.T) { 67 | cases := map[string]string{ 68 | "/api/content": "improperly formatted reference URI: /api/content", 69 | "/api/content?type=Test&noID=1": "reference URI missing 'id' value: /api/content?type=Test&noID=1", 70 | "/api/content?noType=Test&id=1": "reference URI missing 'type' value: /api/content?noType=Test&id=1", 71 | } 72 | 73 | for in, expected := range cases { 74 | _, err := client.ParseReferenceURI(in) 75 | if err.Error() != expected { 76 | fmt.Printf("got: %v, expected: %s\n", err, expected) 77 | t.Fail() 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | // ToValues converts a Content type to a *Values to use with a Ponzu Go client. 12 | func ToValues(p interface{}) (*Values, error) { 13 | // encode p to JSON 14 | j, err := json.Marshal(p) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | // decode json to Values 20 | var kv map[string]interface{} 21 | err = json.Unmarshal(j, &kv) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | vals := NewValues() 27 | for k, v := range kv { 28 | switch v.(type) { 29 | case []interface{}: 30 | vv := v.([]interface{}) 31 | for i := range vv { 32 | vals.Add(k, fmt.Sprintf("%v", vv[i])) 33 | } 34 | default: 35 | vals.Add(k, fmt.Sprintf("%v", v)) 36 | } 37 | } 38 | 39 | return vals, nil 40 | } 41 | 42 | // Target represents required criteria to lookup single content items from the 43 | // Ponzu Content API. 44 | type Target struct { 45 | Type string 46 | ID int 47 | } 48 | 49 | // ParseReferenceURI is a helper method which accepts a reference path / URI from 50 | // a parent Content type, and retrns a Target containing a content item's Type 51 | // and ID. 52 | func ParseReferenceURI(uri string) (Target, error) { 53 | return parseReferenceURI(uri) 54 | } 55 | 56 | func parseReferenceURI(uri string) (Target, error) { 57 | const prefix = "/api/content?" 58 | if !strings.HasPrefix(uri, prefix) { 59 | return Target{}, fmt.Errorf("improperly formatted reference URI: %s", uri) 60 | } 61 | 62 | uri = strings.TrimPrefix(uri, prefix) 63 | 64 | q, err := url.ParseQuery(uri) 65 | if err != nil { 66 | return Target{}, fmt.Errorf("failed to parse reference URI: %s, %v", prefix+uri, err) 67 | } 68 | 69 | if q.Get("type") == "" { 70 | return Target{}, fmt.Errorf("reference URI missing 'type' value: %s", prefix+uri) 71 | } 72 | 73 | if q.Get("id") == "" { 74 | return Target{}, fmt.Errorf("reference URI missing 'id' value: %s", prefix+uri) 75 | } 76 | 77 | // convert query id string to int 78 | id, err := strconv.Atoi(q.Get("id")) 79 | if err != nil { 80 | return Target{}, err 81 | } 82 | 83 | return Target{Type: q.Get("type"), ID: id}, nil 84 | } 85 | -------------------------------------------------------------------------------- /values.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | ) 8 | 9 | // Values is a modified, Ponzu-specific version of the Go standard library's 10 | // url.Values, which implements most all of the same behavior. The exceptions are 11 | // that its `Add(k, v string)` method converts keys into the expected format for 12 | // Ponzu data containing slice type fields, and the `Get(k string)` method returns 13 | // an `interface{}` which will either assert to a `string` or `[]string`. 14 | type Values struct { 15 | values url.Values 16 | keyIndex map[string]int 17 | } 18 | 19 | // Add updates the Values by including a properly formatted form value to data Values. 20 | func (v *Values) Add(key, value string) { 21 | if v.keyIndex[key] == 0 { 22 | v.values.Set(key, value) 23 | v.keyIndex[key]++ 24 | return 25 | } 26 | 27 | if v.keyIndex[key] == 1 { 28 | val := v.values.Get(key) 29 | v.values.Del(key) 30 | k := key + ".0" 31 | v.values.Add(k, val) 32 | } 33 | 34 | keyIdx := fmt.Sprintf("%s.%d", key, v.keyIndex[key]) 35 | v.keyIndex[key]++ 36 | v.values.Set(keyIdx, value) 37 | } 38 | 39 | // NewValues creates and returns an empty set of Ponzu values. 40 | func NewValues() *Values { 41 | return &Values{ 42 | values: make(url.Values), 43 | keyIndex: make(map[string]int), 44 | } 45 | } 46 | 47 | // Del deletes a key and its value(s) from the data set. 48 | func (v *Values) Del(key string) { 49 | if v.keyIndex[key] != 0 { 50 | // delete all key.0, key.1, etc 51 | n := v.keyIndex[key] 52 | for i := 0; i < n; i++ { 53 | v.values.Del(fmt.Sprintf("%s.%d", key, i)) 54 | v.keyIndex[key]-- 55 | } 56 | 57 | v.keyIndex[key] = 0 58 | return 59 | } 60 | 61 | v.values.Del(key) 62 | v.keyIndex[key]-- 63 | } 64 | 65 | // Encode prepares the data set into a URL query encoded string. 66 | func (v *Values) Encode() string { return v.values.Encode() } 67 | 68 | // Get returns an `interface{}` value for the key provided, which will assert to 69 | // either a `string` or `[]string`. 70 | func (v *Values) Get(key string) interface{} { 71 | if strings.Contains(key, ".") { 72 | return v.values.Get(key) 73 | } 74 | 75 | if v.keyIndex[key] == 0 { 76 | return "" 77 | } 78 | 79 | if v.keyIndex[key] == 1 { 80 | return v.values.Get(key) 81 | } 82 | 83 | var results []string 84 | for i := 0; i < v.keyIndex[key]; i++ { 85 | keyIdx := fmt.Sprintf("%s.%d", key, i) 86 | results = append(results, v.values.Get(keyIdx)) 87 | } 88 | 89 | return results 90 | } 91 | 92 | // Set sets a value for a key provided. If Set/Add has already been called, this 93 | // will override all values at the key. 94 | func (v *Values) Set(key, value string) { 95 | if v.keyIndex[key] == 0 { 96 | v.values.Set(key, value) 97 | v.keyIndex[key]++ 98 | return 99 | } 100 | 101 | v.Del(key) 102 | v.keyIndex[key] = 0 103 | v.Set(key, value) 104 | } 105 | --------------------------------------------------------------------------------