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  }