├── 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 |
--------------------------------------------------------------------------------