github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/pkg/jsonapi/jsonapi.go (about) 1 // Package jsonapi is for using the JSON-API format: parsing, serialization, 2 // checking the content-type, etc. 3 package jsonapi 4 5 import ( 6 "compress/gzip" 7 "encoding/json" 8 "io" 9 "net/http" 10 "net/url" 11 "strconv" 12 "strings" 13 14 "github.com/cozy/cozy-stack/pkg/couchdb" 15 "github.com/labstack/echo/v4" 16 ) 17 18 // ContentType is the official mime-type for JSON-API 19 const ContentType = "application/vnd.api+json" 20 21 // Document is JSON-API document, identified by the mediatype 22 // application/vnd.api+json 23 // See http://jsonapi.org/format/#document-structure 24 type Document struct { 25 Data *json.RawMessage `json:"data,omitempty"` 26 Errors ErrorList `json:"errors,omitempty"` 27 Links *LinksList `json:"links,omitempty"` 28 Meta *Meta `json:"meta,omitempty"` 29 Included []interface{} `json:"included,omitempty"` 30 } 31 32 // WriteData can be called to write an answer with a JSON-API document 33 // containing a single object as data into an io.Writer. 34 func WriteData(w io.Writer, o Object, links *LinksList) error { 35 var included []interface{} 36 37 if inc := o.Included(); inc != nil { 38 included = make([]interface{}, len(inc)) 39 for i, o := range inc { 40 data, err := MarshalObject(o) 41 if err != nil { 42 return err 43 } 44 included[i] = &data 45 } 46 } 47 48 data, err := MarshalObject(o) 49 if err != nil { 50 return err 51 } 52 53 doc := Document{ 54 Data: &data, 55 Links: links, 56 Included: included, 57 } 58 return json.NewEncoder(w).Encode(doc) 59 } 60 61 // Data can be called to send an answer with a JSON-API document containing a 62 // single object as data 63 func Data(c echo.Context, statusCode int, o Object, links *LinksList) error { 64 resp := c.Response() 65 w := compressedWriter(c.Request(), resp) 66 defer func() { 67 _ = w.Close() 68 }() 69 resp.WriteHeader(statusCode) 70 return WriteData(w, o, links) 71 } 72 73 // DataList can be called to send an multiple-value answer with a 74 // JSON-API document contains multiple objects. 75 func DataList(c echo.Context, statusCode int, objs []Object, links *LinksList) error { 76 count := len(objs) 77 meta := Meta{Count: &count} 78 return DataListWithMeta(c, statusCode, meta, objs, links) 79 } 80 81 // DataListWithMeta can be called to send a list of Objects with meta like a 82 // count, useful to indicate total number of results with pagination. 83 func DataListWithMeta(c echo.Context, statusCode int, meta Meta, objs []Object, links *LinksList) error { 84 objsMarshaled := make([]json.RawMessage, len(objs)) 85 for i, o := range objs { 86 j, err := MarshalObject(o) 87 if err != nil { 88 return InternalServerError(err) 89 } 90 objsMarshaled[i] = j 91 } 92 93 data, err := json.Marshal(objsMarshaled) 94 if err != nil { 95 return InternalServerError(err) 96 } 97 98 doc := Document{ 99 Data: (*json.RawMessage)(&data), 100 Meta: &meta, 101 Links: links, 102 } 103 104 resp := c.Response() 105 w := compressedWriter(c.Request(), resp) 106 defer func() { 107 _ = w.Close() 108 }() 109 resp.WriteHeader(statusCode) 110 return json.NewEncoder(w).Encode(doc) 111 } 112 113 func compressedWriter(req *http.Request, resp *echo.Response) io.WriteCloser { 114 headers := resp.Header() 115 headers.Set(echo.HeaderContentType, ContentType) 116 headers.Add(echo.HeaderVary, echo.HeaderAcceptEncoding) 117 if !acceptGzipEncoding(req) { 118 return &nopCloser{resp} 119 } 120 headers.Set(echo.HeaderContentEncoding, "gzip") 121 return gzip.NewWriter(resp) 122 } 123 124 // nopCloser adds a Close method to a io.Writer (io.NopCloser does that for 125 // io.Reader). 126 type nopCloser struct { 127 io.Writer 128 } 129 130 func (nopCloser) Close() error { return nil } 131 132 func acceptGzipEncoding(req *http.Request) bool { 133 return strings.Contains(req.Header.Get(echo.HeaderAcceptEncoding), "gzip") 134 } 135 136 // DataRelations can be called to send a Relations page, 137 // a list of ResourceIdentifier 138 func DataRelations(c echo.Context, statusCode int, refs []couchdb.DocReference, meta *Meta, links *LinksList, included []Object) error { 139 data, err := json.Marshal(refs) 140 if err != nil { 141 return InternalServerError(err) 142 } 143 144 doc := Document{ 145 Data: (*json.RawMessage)(&data), 146 Meta: meta, 147 Links: links, 148 } 149 150 if included != nil { 151 includedMarshaled := make([]interface{}, len(included)) 152 for i, o := range included { 153 j, err := MarshalObject(o) 154 if err != nil { 155 return InternalServerError(err) 156 } 157 includedMarshaled[i] = &j 158 } 159 doc.Included = includedMarshaled 160 } 161 162 resp := c.Response() 163 resp.Header().Set(echo.HeaderContentType, ContentType) 164 resp.WriteHeader(statusCode) 165 return json.NewEncoder(resp).Encode(doc) 166 } 167 168 // DataError can be called to send an error answer with a JSON-API document 169 // containing a single value error. 170 func DataError(c echo.Context, err *Error) error { 171 doc := Document{ 172 Errors: ErrorList{err}, 173 } 174 resp := c.Response() 175 resp.Header().Set(echo.HeaderContentType, ContentType) 176 resp.WriteHeader(err.Status) 177 return json.NewEncoder(resp).Encode(doc) 178 } 179 180 // DataErrorList can be called to send an error answer with a JSON-API document 181 // containing multiple errors. 182 func DataErrorList(c echo.Context, errs ...*Error) error { 183 doc := Document{ 184 Errors: errs, 185 } 186 if len(errs) == 0 { 187 panic("jsonapi.DataErrorList called with empty list.") 188 } 189 resp := c.Response() 190 resp.Header().Set(echo.HeaderContentType, ContentType) 191 resp.WriteHeader(errs[0].Status) 192 return json.NewEncoder(resp).Encode(doc) 193 } 194 195 // Bind is used to unmarshal an input JSONApi document. It binds an 196 // incoming request to a attribute type. 197 func Bind(body io.Reader, attrs interface{}) (*ObjectMarshalling, error) { 198 decoder := json.NewDecoder(body) 199 var doc *Document 200 if err := decoder.Decode(&doc); err != nil { 201 return nil, err 202 } 203 if doc == nil || doc.Data == nil { 204 return nil, BadJSON() 205 } 206 var obj *ObjectMarshalling 207 if err := json.Unmarshal(*doc.Data, &obj); err != nil { 208 return nil, err 209 } 210 if obj != nil && obj.Attributes != nil && attrs != nil { 211 if err := json.Unmarshal(*obj.Attributes, &attrs); err != nil { 212 return nil, err 213 } 214 } 215 return obj, nil 216 } 217 218 // BindCompound is used to unmarshal an compound input JSONApi document. 219 func BindCompound(body io.Reader) ([]*ObjectMarshalling, error) { 220 decoder := json.NewDecoder(body) 221 var doc *Document 222 if err := decoder.Decode(&doc); err != nil { 223 return nil, err 224 } 225 if doc.Data == nil { 226 return nil, BadJSON() 227 } 228 var objs []*ObjectMarshalling 229 if err := json.Unmarshal(*doc.Data, &objs); err != nil { 230 return nil, err 231 } 232 return objs, nil 233 } 234 235 // BindRelations extracts a Relationships request ( a list of ResourceIdentifier) 236 func BindRelations(req *http.Request) ([]couchdb.DocReference, error) { 237 var out []couchdb.DocReference 238 decoder := json.NewDecoder(req.Body) 239 var doc *Document 240 if err := decoder.Decode(&doc); err != nil { 241 return nil, err 242 } 243 if doc.Data == nil { 244 return nil, BadJSON() 245 } 246 // Attempt Unmarshaling either as ResourceIdentifier or []ResourceIdentifier 247 if err := json.Unmarshal(*doc.Data, &out); err != nil { 248 var ri couchdb.DocReference 249 if err = json.Unmarshal(*doc.Data, &ri); err != nil { 250 return nil, err 251 } 252 out = []couchdb.DocReference{ri} 253 return out, nil 254 } 255 return out, nil 256 } 257 258 // PaginationCursorToParams transforms a Cursor into url.Values 259 // the url.Values contains only keys page[limit] & page[cursor] 260 // if the cursor is Done, the values will be empty. 261 func PaginationCursorToParams(cursor couchdb.Cursor) (url.Values, error) { 262 v := url.Values{} 263 264 if !cursor.HasMore() { 265 return v, nil 266 } 267 268 switch c := cursor.(type) { 269 case *couchdb.StartKeyCursor: 270 cursorObj := []interface{}{c.NextKey, c.NextDocID} 271 cursorBytes, err := json.Marshal(cursorObj) 272 if err != nil { 273 return nil, err 274 } 275 v.Set("page[limit]", strconv.Itoa(c.Limit)) 276 v.Set("page[cursor]", string(cursorBytes)) 277 278 case *couchdb.SkipCursor: 279 v.Set("page[limit]", strconv.Itoa(c.Limit)) 280 v.Set("page[skip]", strconv.Itoa(c.Skip)) 281 } 282 283 return v, nil 284 } 285 286 // ExtractPaginationCursor creates a Cursor from context Query. 287 func ExtractPaginationCursor(c echo.Context, defaultLimit, maxLimit int) (couchdb.Cursor, error) { 288 limit := defaultLimit 289 if limitString := c.QueryParam("page[limit]"); limitString != "" { 290 reqLimit, err := strconv.ParseInt(limitString, 10, 32) 291 if err != nil { 292 return nil, NewError(http.StatusBadRequest, "page limit is not a number") 293 } 294 limit = int(reqLimit) 295 } 296 if maxLimit > 0 && limit > maxLimit { 297 limit = maxLimit 298 } 299 300 if cursor := c.QueryParam("page[cursor]"); cursor != "" { 301 var parts []interface{} 302 err := json.Unmarshal([]byte(cursor), &parts) 303 if err != nil { 304 return nil, Errorf(http.StatusBadRequest, "bad json cursor %s", cursor) 305 } 306 307 if len(parts) != 2 { 308 return nil, Errorf(http.StatusBadRequest, "bad cursor length %s", cursor) 309 } 310 nextKey := parts[0] 311 nextDocID, ok := parts[1].(string) 312 if !ok { 313 return nil, Errorf(http.StatusBadRequest, "bad cursor id %s", cursor) 314 } 315 316 return couchdb.NewKeyCursor(limit, nextKey, nextDocID), nil 317 } 318 319 if skipString := c.QueryParam("page[skip]"); skipString != "" { 320 reqSkip, err := strconv.Atoi(skipString) 321 if err != nil { 322 return nil, NewError(http.StatusBadRequest, "page skip is not a number") 323 } 324 return couchdb.NewSkipCursor(limit, reqSkip), nil 325 } 326 327 return couchdb.NewKeyCursor(limit, nil, ""), nil 328 }