Skip to main content
Version: 2.x

RESTful API Handler

Introduction

The RESTful-style API handler exposes CRUD APIs as RESTful endpoints using JSON:API as transportation format. The API handler is not meant to be used directly; instead, you should use it together with a server adapter which handles the request and response API for a specific framework.

It can be created as the following:

/src/app/api/model/[...path]/route.ts
import { NextRequestHandler } from '@zenstackhq/server/next';
import { RestApiHandler } from '@zenstackhq/server/api';
import { getPrisma } from '~/lib/db';

const handler = NextRequestHandler({
getPrisma,
useAppDir: true,
handler: RestApiHandler({ endpoint: 'http://myhost/api' })
});

export {
handler as GET,
handler as POST,
handler as PUT,
handler as PATCH,
handler as DELETE,
};

The factory function accepts an options object with the following fields:

  • endpoint

    Required. A string field representing the base URL of the RESTful API, used for generating resource links.

  • pageSize

    Optional. A number field representing the default page size for listing resources and relationships. Defaults to 100. Set to Infinity to disable pagination.

Endpoints and Features

The RESTful API handler conforms to the the JSON:API v1.1 specification for its URL design and input/output format. The following sections list the endpoints and features are implemented. The examples refer to the following schema modeling a blogging app:

model User {
id Int @id @default(autoincrement())
email String
posts Post[]
}

model Profile {
id Int @id @default(autoincrement())
gender String
user User @relation(fields: [userId], references: [id])
userId Int @unique
}

model Post {
id Int @id @default(autoincrement())
title String
published Boolean @default(false)
viewCount Int @default(0)
author User @relation(fields: [authorId], references: [id])
authorId Int
comments Comment[]
}

model Comment {
id Int @id @default(autoincrement())
content String
post Post @relation(fields: [postId], references: [id])
postId Int
}

Listing resources

A specific type of resource can be listed using the following endpoint:

GET /:type

Status codes

  • 200: The request was successful and the response body contains the requested resources.
  • 400: The request was malformed.
  • 403: The request was forbidden.
  • 404: The requested resource type does not exist.
  • 422: The request violated data validation rules.

Examples

GET /post
{
"meta": {
"total": 1
},
"data": [
{
"attributes": {
"authorId": 1,
"published": true,
"title": "My Awesome Post",
"viewCount": 0
},
"id": 1,
"links": {
"self": "http://myhost/api/post/1"
},
"relationships": {
"author": {
"data": { "id": 1, "type": "user" },
"links": {
"related": "http://myhost/api/post/1/author/1",
"self": "http://myhost/api/post/1/relationships/author/1"
}
}
},
"type": "post"
}
],
"jsonapi": {
"version": "1.1"
},
"links": {
"first": "http://myhost/api/post?page%5Blimit%5D=100",
"last": "http://myhost/api/post?page%5Boffset%5D=0",
"next": null,
"prev": null,
"self": "http://myhost/api/post"
}
}

Fetching a resource

A unique resource can be fetched using the following endpoint:

GET /:type/:id

Status codes

  • 200: The request was successful and the response body contains the requested resource.
  • 400: The request was malformed.
  • 403: The request was forbidden.
  • 404: The requested resource type or ID does not exist.

Examples

GET /post/1
{
"data": {
"attributes": {
"authorId": 1,
"published": true,
"title": "My Awesome Post",
"viewCount": 0
},
"id": 1,
"links": {
"self": "http://myhost/api/post/1"
},
"relationships": {
"author": {
"data": { "id": 1, "type": "user" },
"links": {
"related": "http://myhost/api/post/1/author/1",
"self": "http://myhost/api/post/1/relationships/author/1"
}
}
},
"type": "post"
},
"jsonapi": {
"version": "1.1"
},
"links": {
"self": "http://myhost/api/post/1"
}
}

Fetching relationships

A resource's relationships can be fetched using the following endpoint:

GET /:type/:id/relationships/:relationship

Status codes

  • 200: The request was successful and the response body contains the requested relationships.
  • 400: The request was malformed.
  • 403: The request was forbidden.
  • 404: The requested resource type, ID, or relationship does not exist.

Examples

  1. Fetching a to-one relationship

    GET /post/1/relationships/author
    {
    "data" : { "id" : 1, "type" : "user" },
    "jsonapi" : {
    "version" : "1.1"
    },
    "links" : {
    "self" : "http://myhost/api/post/1/relationships/author"
    }
    }
  2. Fetching a to-many relationship

    GET /user/1/relationships/posts
    {
    "data" : [
    { "id" : 1, "type" : "post" },
    { "id" : 2, "type" : "post" }
    ],
    "jsonapi" : {
    "version" : "1.1"
    },
    "links" : {
    "first" : "http://myhost/api/user/1/relationships/posts?page%5Blimit%5D=100",
    "last" : "http://myhost/api/user/1/relationships/posts?page%5Boffset%5D=0",
    "next" : null,
    "prev" : null,
    "self" : "http://myhost/api/user/1/relationships/posts"
    }
    }
GET /:type/:id/:relationship

Status codes

  • 200: The request was successful and the response body contains the requested relationship.
  • 400: The request was malformed.
  • 403: The request was forbidden.
  • 404: The requested resource type, ID, or relationship does not exist.

Examples

GET /post/1/author
{
"data" : {
"attributes" : {
"email" : "emily@zenstack.dev",
"name" : "Emily"
},
"id" : 1,
"links" : {
"self" : "http://myhost/api/user/1"
},
"relationships" : {
"posts" : {
"links" : {
"related" : "http://myhost/api/user/1/posts",
"self" : "http://myhost/api/user/1/relationships/posts"
}
}
},
"type" : "user"
},
"jsonapi" : {
"version" : "1.1"
},
"links" : {
"self" : "http://myhost/api/post/1/author"
}
}

Fine-grained data fetching

Filtering

You can use the filter[:selector1][:selector2][...]=value query parameter family to filter resource collections or relationship collections.

Examples
  1. Equality filter against plain field

    GET /api/post?filter[published]=false
  2. Equality filter against relationship

    Relationship field can be filtered directly by its id.

    GET /api/post?filter[author]=1

    If the relationship is to-many, the filter has "some" semantic and evaluates to true if any of the items in the relationship matches.

    GET /api/user?filter[posts]=1
  3. Filtering with multiple values

    Multiple filter values can be separated by comma. Items statisfying any of the values will be returned.

    GET /api/post?filter[author]=1,2
  4. Multiple filters

    A request can carry multiple filters. Only items statisfying all filters will be returned.

    GET /api/post?filter[author]=1&filter[published]=true
  5. Deep filtering

    A filter can carry multiple field selectors to reach into relationships.

    GET /api/post?filter[author][name]=Emily

    When reaching into a to-many relationship, the filter has "some" semantic and evaluates to true if any of the items in the relationship matches.

    GET /api/user?filter[posts][published]=true
  6. Filtering with comparison operators

    Filters can go beyond equality by appending an "operator suffix".

    GET /api/post?filter[viewCount$gt]=100

    The following operators are supported:

    • $lt

      Less than

    • $lte

      Less than or equal to

    • $gt

      Greater than

    • $gte

      Greater than or equal to

    • $contains

      String contains

    • $icontains

      Case-insensitive string contains

    • $search

      String full-text search

    • $startsWith

      String starts with

    • $endsWith

      String ends with

    • $has

      Collection has value

    • $hasEvery

      Collection has every element in value

    • $hasSome

      Collection has some elements in value

    • $isEmpty

      Collection is empty

Sorting

You can use the sort query parameter to sort resource collections or relationship collections. The value of the parameter is a comma-separated list of fields names. The order of the fields in the list determines the order of sorting. By default, sorting is done in ascending order. To sort in descending order, prefix the field name with a minus sign.

Examples
GET /api/post?sort=createdAt,-viewCount

Pagination

When creating a RESTful API handler, you can pass in a pageSize option to control pagination behavior of fetching a collection of resources, related resources, and relationships. By default the page size is 100, and you can disable pagination by setting pageSize option to Infinity.

When fetching a collection resource or relationship, you can use the page[offset]=value and page[limit]=value query parameter family to fetch a specific page. They're mapped to skip and take parameters in the query arguments sent to PrismaClient.

The response data of collection fetching contains pagination links that facilitate navigating through the collection. The "meta" section also contains the total count available. E.g.:

{
"meta": {
"total": 10
},
"data" : [
...
],
"links" : {
"first" : "http://myhost/api/post?page%5Blimit%5D=2",
"last" : "http://myhost/api/post?page%5Boffset%5D=4",
"next" : "http://myhost/api/post?page%5Boffset%5D=4&page%5Blimit%5D=2",
"prev" : "http://myhost/api/post?page%5Boffset%5D=0&page%5Blimit%5D=2",
"self" : "http://myhost/api/post"
}
}
Examples
  1. Fetching a specific page of resources

    GET /api/post?page[offset]=10&page[limit]=5
  2. Fetching a specific page of relationships

    GET /api/user/1/relationships/posts?page[offset]=10&page[limit]=5
  3. Fetching a specific page of related resources

    GET /api/user/1/posts?page[offset]=10&page[limit]=5

You can use the include query parameter to include related resources in the response. The value of the parameter is a comma-separated list of fields names. Field names can contain dots to reach into nested relationships.

When including related resources, the response data takes the form of Compound Documents and contains a included field carrying normalized related resources. E.g.:

{
"data" : [
{
"attributes" : {
...
},
"id" : 1,
"relationships" : {
"author" : {
"data" : { "id" : 1, "type" : "user" }
}
},
"type" : "post"
}
],
"included" : [
{
"attributes" : {
"email" : "emily@zenstack.dev",
"name" : "Emily"
},
"id" : 1,
"links" : {
"self" : "http://myhost/api/user/1"
},
"relationships" : {
"posts" : {
"links" : {
"related" : "http://myhost/api/user/1/posts",
"self" : "http://myhost/api/user/1/relationships/posts"
}
}
},
"type" : "user"
}
]
}
Examples
  1. Including a direct relationship

    GET /api/post?include=author
  2. Including a deep relationship

    GET /api/post?include=author.profile
  3. Including multiple relationships

    GET /api/post?include=author,comments

Creating a resource

A new resource can be created using the following endpoint:

POST /:type

Status codes

  • 201: The request was successful and the resource was created.
  • 400: The request was malformed.
  • 403: The request was forbidden.
  • 404: The requested resource type does not exist.

Examples

  1. Creating a resource

    POST /user
    {
    "data": {
    "type": "user",
    "attributes": {
    "name": "Emily",
    "email": "emily@zenstack.dev"
    }
    }
    }
  2. Creating a resource with relationships attached

    POST /user
    {
    "data": {
    "type": "user",
    "attributes": {
    "name": "Emily",
    "email": "emily@zenstack.dev"
    },
    "relationships": {
    "posts": {
    "data": [{ "type": "post", "id": 1 }]
    }
    }
    }
    }

Updating a resource

A resource can be updated using the following endpoints:

PUT /:type/:id
PATCH /:type/:id

Both PUT and PATCH do partial update and has exactly the same behavior.

info

Besides plain fields, you can also include relationships in the request body. Please note that this won't update the related resource; instead if only replaces the relationships. If you update a to-many relationship, the new collection will entirely replace the old one.

Relationships can also be manipulated directly. See Manipulating Relationships for more details.

Status codes

  • 200: The request was successful and the resource was updated.
  • 400: The request was malformed.
  • 403: The request was forbidden.
  • 404: The requested resource type or ID does not exist.

Examples

  1. Updating a resource

    PUT /post/1
    {
    "data": {
    "type": "post",
    "attributes": {
    "title": "My Awesome Post"
    }
    }
    }
  2. Updating a resource's relationships

    PUT /user/1
    {
    "data": {
    "type": "user",
    "relationships": {
    "posts": {
    "data": [{ "type": "post", "id": 2 }]
    }
    }
    }
    }

Upserting a resource

JSON:API didn't specify a convention for "upsert" operations. ZenStack uses a variation of the "create" operation to represent "upsert", and uses the request meta to indicate the intention. See details in examples.

POST /:type

Status codes

  • 201: The request was successful and the resource was created.
  • 200: The request was successful and the resource was updated.
  • 400: The request was malformed.
  • 403: The request was forbidden.
  • 404: The requested resource type does not exist.

Examples

POST /user
{
"data": {
"type": "user",
"attributes": {
"id": 1,
"name": "Emily",
"email": "emily@zenstack.dev"
}
},
"meta": {
"operation": "upsert",
"matchFields": ["id"],
}
}

The meta.operation field must be "upsert", and the meta.matchFields field must be an array of field names that are used to determine if the resource already exists. If an existing resource is found, "update" operation is conducted, otherwise "create". The meta.matchFields fields must be unique fields, and they must have corresponding entries in data.attributes.

Deleting a resource

A resource can be deleted using the following endpoint:

Status codes

  • 204: The request was successful and the resource was deleted.
  • 403: The request was forbidden.
  • 404: The requested resource type or ID does not exist.
DELETE /:type/:id

Manipulating relationships

Relationships can be manipulated using the following endpoints:

Adding to a to-many relationship

POST /:type/:id/relationships/:relationship
Status codes
  • 200: The request was successful and the relationship was updated.
  • 403: The request was forbidden.
  • 404: The requested resource type, ID, or relationship does not exist.
Examples
POST /user/1/relationships/posts
{
"data": [
{ "type": "post", "id": "1" },
{ "type": "post", "id": "2" }
]
}

Updating a relationship (to-one or to-many)

PUT /:type/:id/relationships/:relationship
PATCH /:type/:id/relationships/:relationship
info

PUT and PATCH has exactly the same behavior and both relace the existing relationships with the new ones entirely.

Status codes
  • 200: The request was successful and the relationship was updated.
  • 403: The request was forbidden.
  • 404: The requested resource type, ID, or relationship does not exist.
Examples
  1. Replacing a to-many relationship

    PUT /user/1/relationships/posts
    {
    "data": [
    { "type": "post", "id": "1" },
    { "type": "post", "id": "2" }
    ]
    }
  2. Replacing a to-one relationship

    PUT /post/1/relationships/author
    {
    "data": { "type": "user", "id": "2" }
    }
  3. Clearing a to-many relationship

    PUT /user/1/relationships/posts
    {
    "data": []
    }
  4. Clearing a to-one relationship

    PUT /post/1/relationships/author
    {
    "data": null
    }

Compound ID Fields

Prisma allows a model to have compound ID fields, e.g.:

model Post {
id1 Int
id2 Int
@@id([id1, id2])
}

The JSON:API specification doesn't have a native way to represent compound IDs. To mitigate this limitation, when returning an entity with compound IDs, ZenStack synthesizes an "id" field to carry the ID values joined with underscore:

{
"data": {
"id": "1_2",
"attributes": {
"id1": 1,
"id2": 2,
...
},
"links" : {
"self" : "http://localhost:3100/api/model/post/1_2"
},
...
}
}

You can use this ID value convension in places where an ID is needed, e.g., reading a single entity.

GET /post/1_2

Limitations:

  1. Joining ID values with underscore implies that the ID values themselves cannot contain underscores. We'll make the separator configurable in the future.
  2. Prisma allows you to create a name for the compound ID field. Such usage is not yet supported by the RESTful API handler.

Special thanks to Thomas Sunde Nielsen for implementing this feature!

Serialization

ZenStack uses superjson to serialize and deserialize data. Superjson generates two parts during serialization:

  • json:

    The JSON-compatible serialization result.

  • meta:

    The serialization metadata including information like field types that facilitates deserialization.

If the data only involves simple data types, the serialization result is the same as regular JSON.stringify, and no meta part is generated. However, for complex data types (like Bytes, Decimal, etc.), a meta object will be generated, which needs to be carried along when sending the request, and will also be included in the response.

When sending requests, if superjson-serializing the request body results in a meta object, it should be put into a { "serialization": meta } object and included in the meta field of the request body. For example, if you have a bytes field of type Bytes, the request body should look like:

POST /post
{
"data": {
"type": "post",
"attributes": {
...
"bytes": "AQID" // base64-encoded bytes
}
},
"meta": {
"serialization": {"values": { "data.attributes.bytes": [[ "custom", "Bytes"]] } }
}
}

Correspondingly, the response body of a query may look like:

GET /post/1
{
"data": {
"id": "1",
"type": "post",
"attributes": {
...
"bytes": "AQID" // base64-encoded bytes
}
},
"meta": {
"serialization": {"values": { "data.attributes.bytes": [[ "custom", "Bytes"]] } }
}
}

You should use the meta.serialization field value to superjson-deserialize the response body.

Data Type Serialization Format

  • DateTime

    ISO 8601 string

  • Bytes

    Base64-encoded string

  • BigInt

    String representation

  • Decimal

    String representation

Error Handling

An error response is an object containing the following fields:

  • errors

    An array of error objects, each containing the following fields:

    • code: string, error code
    • status: number, HTTP status code
    • title: string, error title
    • detail: string, error detail
    • prismaCode: string, Prisma error code, if the error is thrown by Prisma

Example

{
"errors" : [
{
"code" : "unsupported-model",
"detail" : "Model foo doesn't exist",
"status" : 404,
"title" : "Unsupported model type"
}
]
}
Comments
Feel free to ask questions, give feedback, or report issues.

Don't Spam


You can edit/delete your comments by going directly to the discussion, clicking on the 'comments' link below