The jsonapi package is a utility for marshaling and unmarshaling Go structs to and from JSON:API v1.1 formatted JSON.
Features:
- Struct tags define the mapping between struct fields and the JSON:API id, attributes, relationships and metadata.
- Marshaling and unmarshaling behaviour can be customised by implementing the
ResourceMarshalerandResourceUnmarshalerinterfaces, respectively. - Exposes an API similar to the standard
encoding/jsonpackage. - Supports anonymous/embedded struct fields.
Planned feaures:
- Strict mode that enforces JSON:API compliant output.
- Marshaling and unmarshaling arrays of resources.
- Marshaling and unmarshaling top-level JSON:API documents
Import with
import (
"github.com/max-waters/jsonapi"
)
Two functions are exposed:
MarshalResource(a any) ([]byte, error)
UnmarshalResource(data []byte, a any) errorMarshalResource returns the JSON:API encoding of a, and UnmarshalResource parses the JSON:API-encoded bytes data and stores the result in the value pointed to by a.
Go code:
type Article struct {
ID int `jsonapi:"id,articles,string"`
Title string `jsonapi:"attr,title"`
Author int `jsonapi:"rel,author,people,string"`
Comments []int `jsonapi:"rel,comments,comments,string"`
Deleted bool `jsonapi:"meta,deleted"`
}
a := Article{
ID: 1,
Title: "Hello World",
Author: 2,
Comments: []int{3, 4},
Deleted: false,
}
b, err := jsonapi.MarshalResource(&a)
if err != nil {
// handle error
}
fmt.Println(string(b))The resulting JSON:API:
{
"type": "articles",
"id": "1",
"meta": {
"deleted": false
},
"attributes": {
"title": "Hello World"
},
"relationships": {
"author": {
"data": {
"type": "people",
"id": "2"
}
},
"comments": {
"data": [
{
"type": "comments",
"id": "3"
},
{
"type": "comments",
"id": "4"
}
]
}
}
}The mapping between struct fields and the JSON:API id, attributes, relationships and metadata is defined with struct tags. The marshal and unmarshal functions will look for these tags in the top-level fields of in the input struct, and those in any anonymous struct fields. The values of these fields are marshaled and unmarshaled into the appropriate location in the resulting JSON:API using the encoding/json package.
Note that if a struct field has no jsonapi tag, then it is assumed to be an attribute (see below) with the encoding/json default name. A struct field can be exlcuded from the mapping with the "ignore" tag, jsonapi:"-".
The id tag defines the resource's primary id:
`jsonapi:"id,{type},[options]"`The tagged field's value is mapped to the resource's "id" field, and the {type} argument defines the content of the "type" field. The field value is marshaled and unmarshaled with the encoding/json package.
Note that the jsonapi package does not (currently) enforce the JSON:API requirement that the "id" field be a string. However, the string option will encode floating point or integer values as JSON strings, allowing them to be used as valid JSON:API identifiers.
The omitempty option will exclude zero-valued values from the resulting JSON, allowing for empty IDs (eg for server-side ID generation).
Struct tags:
type Article struct {
ID int `jsonapi:"id,articles,string"`
}
a := Article{
ID: 1,
}JSON:API:
{
"type": "articles",
"id": "1",
}Struct tags:
type Article struct {
ID int `jsonapi:"id,articles,string,omitempty"`
}
a := Article{}JSON:API:
{
"type": "articles"
}An attribute is defined by providing either an attr tag, or no jsonapi tag at all:
`jsonapi:"attr,{name},[options]"`The field's value will be mapped to an attribute with the key specified by {name}. If no jsonapi tag is defined, or the {name} argument is empty, then the encoding/json default is used instead, ie either the name defined in the json tag, or the declared field name if none is found. The field value is marshaled and unmarshaled with the encoding/json package.
The attr tag supports the string and omitempty options, which encode numeric values as JSON strings, and omit zero-valued fields, respectively.
Struct tags:
type Copyright struct {
Owner string `json:"owner"`
Date time.Time `json:"date"`
}
type Article struct {
Title string `json:"title"`
Content string `jsonapi:"content,omitempty"`
Copyright Copyright `jsonapi:"attr,copyright"`
}
a := Article{
Title: "Hello World",
Copyright: Copyright {
Owner: "Publishing Ltd",
Date: time.Now()
}
}JSON:API:
{
"attributes": {
"title": "Hello World",
"copyright": {
"owner": "Publishing Ltd",
"date": "2024-12-12T21:46:43.552855+11:00"
}
},
}The rel tag defines a relationship:
`jsonapi:"rel,{name},{type},[options]"`Any field annotated with a rel tag will be mapped to a relationship with the key specified by {name}. If the {name} argument is empty, then the encoding/json default is used instead, ie either the name defined in the json tag, or the declared field name if none is found.
The field's declared type determines whether it maps to a to-one or a to-many relationship. Array and slices of anything except bytes (or pointers to these), will be mapped to a to-many relationship, and all other types are mapped to a to-one relationship. For to-one relationships, the field's value maps to the relationship's "id" field, and the {type} argument defines the "type" field. For to-many relationships, each element in the array or slice defines the "id" of a related resource. The IDs are marshaled and unmarshaled with the encoding/json package.
While the jsonapi package does not (currently) enforce the JSON:API requirement that the "id" field be a string, the string option will encode floating point or integer IDs as JSON strings, allowing them to be used as valid JSON:API identifiers. And the omitempty option will exclude relationships with zero-valued valued IDs from the resulting JSON.
Struct tags:
type Article struct {
Author int `jsonapi:"rel,author,people,string"`
Comments []int `jsonapi:"rel,comments,comments,string"`
}
a := Article{
Author: 2,
Comments: []int{3, 4},
}JSON:API:
{
"relationships": {
"author": {
"data": {
"type": "people",
"id": "2"
}
},
"comments": {
"data": [
{
"type": "comments",
"id": "3"
},
{
"type": "comments",
"id": "4"
}
]
}
}
}Struct tags:
type Article struct {
Author int `jsonapi:"rel,author,people,string"`
Comments []int `jsonapi:"rel,comments,comments,string"`
}
a := Article{
Comments: []int{3, 4},
}JSON:API:
{
"relationships": {
"comments": {
"data": [
{
"type": "comments",
"id": "3"
},
{
"type": "comments",
"id": "4"
}
]
}
}
}The meta tag defines a metadata item:
`jsonapi:"meta,{name},[options]"`The field's value will be mapped to a metadata item with the key specified by {name}. If the {name} argument is empty, then the encoding/json default is used instead, ie either the name defined in the json tag, or the declared field name if none is found. The field value is marshaled and unmarshaled with the encoding/json package.
The meta tag supports the string and omitempty options, which encode numeric values as JSON strings, and omit zero-valued fields, respectively.
The link tag defines a link:
`jsonapi:"link,{name},[options]"`The field's value will be mapped to a link with the key specified by {name}. If the {name} argument is empty, then the encoding/json default is used instead, ie either the name defined in the json tag, or the declared field name if none is found. The field value is marshaled and unmarshaled with the encoding/json package.
Note that the jsonapi package does not (currently) enforce the requirement that all links be either valid URIs or link objects.
The link tag supports the string and omitempty options, which encode numeric values as JSON strings, and omit zero-valued fields, respectively.
Anonymous (ie, embedded) struct fields are "promoted" and treated as though their members are declared in their parent type:
type SessionAuthz struct {
Editable bool `jsonapi:"attr,editable"`
Deletable bool `jsonapi:"attr,deletable"`
}
type Article struct {
SessionAuthz
Title string `jsonapi:"attr,title"`
}
a := Article{
SessionAuthz: SessionAuthz{
Editable: true,
Deletable: false,
},
Title: "Hello World",
}JSON:API:
{
"attributes": {
"deletable": false,
"editable": true,
"title": "Hello World"
}
}Names clashes are resolved with standard Go promotion rules, as used by the encoding/json package. If two or more attr, rel or meta fields have the same name, then a selection is made based on the fields' nesting depth, then the presence of a jsonapi tag, then the presence of a json tag. If no single preferred field is found, then all clashing fields are excluded from the marshaling and unmarshaling.
The jsonapi package provides two interfaces and a number of functional options to help with custom marshaling and unmarshaling.
These interfaces allow a type to marshal or unmarshal itself to or from JSON:API:
type ResourceMarshaler interface {
MarshalJsonApiResource() ([]byte, error)
}
type ResourceUnmarshaler interface {
UnmarshalJsonApiResource([]byte) error
}In this example, the Article type formats the created attribute as an RFC3339 timestamp by creating an alias type, and then calling the jsonapi marshaling and unmarshaling functions:
type Article struct {
ID int
Created time.Time
}
func (a *Article) MarshalJsonApiResource() ([]byte, error) {
type alias struct {
ID int `jsonapi:"id,articles,string"`
Created string `jsonapi:"attr,created"`
}
b := alias{
ID: a.ID
Created: a.Created.Format(time.RFC3339),
}
return jsonapi.MarshalResource(&b)
}
func (a *Article) UnmarshalJsonApiResource(data []byte) error {
type alias struct {
ID int `jsonapi:"id,articles,string"`
Created string `jsonapi:"attr,created"`
}
b := alias{}
if err := jsonapi.UnmarshalResource(data, &b); err != nil {
return err
}
created, err := time.Parse(time.RFC3339, b.Created)
if err != nil {
return err
}
a.ID = b.ID
a.Created = created
return nil
}The functional options WithResourceLinker and WithRelationshipLinker allow for resource and relationship links to be generated from struct tags as well as information not contained in the resource.
The WithResourceLinker option accepts a function with the signature func(a any, r jsonapi.ResourceIdentifier) (map[string]jsonapi.Link, error) that should return all links for the supplied resource and JSON:API id. The function will be called for every resource.
The WithRelationshipLinker option accepts a function with the signature func(r any, id jsonapi.ResourceIdentifier, rel string, toOne bool, data ...jsonapi.ResourceIdentifier) (map[string]jsonapi.Link, error) that should return all links for the supplied relationship on the supplied resource. The function will be called for every relationship on every resource.
In this example, self and related links are generated from the type names declared in the resource's struct tags and a pre-configured URL base:
func InitResourceLinker(urlBase string) jsonapi.ResourceLinker {
return func(r any, id jsonapi.ResourceIdentifier) (map[string]jsonapi.Link, error) {
return map[string]jsonapi.Link{
"self": jsonapi.LinkUri{Uri: fmt.Sprintf("%s/%s/%s", urlBase, id.Type, id.Id)},
}, nil
}
}
func InitRelationshipLinker(urlBase string) jsonapi.RelationshipLinker {
return func(r any, id jsonapi.ResourceIdentifier, rel string, toOne bool, data ...jsonapi.ResourceIdentifier) (map[string]jsonapi.Link, error) {
return map[string]Link{
"self": jsonapi.LinkUri{Uri: fmt.Sprintf("%s/%s/%s/relationships/%s", urlBase, id.Type, id.Id, rel)},
"related": jsonapi.LinkUri{Uri: fmt.Sprintf("%s/%s/%s/%s", urlBase, id.Type, id.Id, rel)},
}, nil
}
}
type Article struct {
ID int
Comments []int
}
a := Article{
Id: 4,
Comments: []int{5}
}
urlBase := "https://example.com"
jsonapi.MarshalResource(a,
jsonapi.WithResourceLinker(InitResourceLinker(urlBase)),
jsonapi.WithRelationshipLinker(InitRelationshipLinker(urlBase)),
)JSON:API:
{
"type": "articles",
"id": "4",
"relationships": {
"comments": {
"data": [
{
"type": "comment",
"id": 5
}
],
"links": {
"related": "https://example.com/articles/4/comments",
"self": "https://example.com/articles/4/relationships/comments"
}
}
},
"links": {
"self": "https://example.com/articles/4"
}
}