Fork me on GitHub

Motivation

We believe that hybrid solutions are more robust than ultimate ones.

Trying to have a balance between simplicity and power we found out this convention. The major idea is to provide API making patterns with respect to the Server side and comfortable for the Client side.

Note
Make sure you checked the Best Practices before usage. There are many answers to questions that you may have.

Documentation convention

Italic - local definitions or from glossary
BOLD - technologies or definitions with external documentation
/entity/{id} - id is variable part of URI path

Example 1. Examples and explanations

JSON is used as serialization format for examples bellow

{
  "field": "value"
}

Glossary

Identity

unique identifier

Structure

object without identity

Entity

object with identity field (e.g. id)

Model

contract which describes which fields are in Structure or Entity

View

read-only Model which is used to represent Entity from other point of view

Filters

a set of conditions to reduce response.
Filters are passed via GET request parameters: <field>-<filter-keyword>=<value>.
filter-keywords have next mapping to comparison operations: eq (=), gt (>), lg (<), gte(>=), lte ().
value must have the same tape with filtered field

Example 2. Filtering comments by dates

GET request example.org/api/comments?createdAt-gte=2000-01-01&createdAt-lt=2000-01-10

There can be two additional steps: order and limit. It’s assumed that limit is evaluated always as the last step.

Order is GET request parameters: order=<field1-asc>[,<field2-desc>];
Limit is GET request parameter: limit=<count-of-results>

Example 3. Filtering with order and limit

GET request example.org/api/comments?createdAt-gte=2000-01-01&limit=1&order=createdAt-desc,id-asc

Filtrator

a special Model to filter and navigate across arrays. It’s returned as a response when more than one entity was queried.

Example 4. Response with Filtrator in a body
{
  "items": [{},{},], (1)
  "hasMore": true,
  "filter" : {          (2)
    "limit" : 20
  }
}
  1. found entities according to filter

  2. filter used in request (including implicit)

Counter

any field which represent an amount of items.
Also can be used as a View. See Counter example for more details.

Operation

a system level Entity to track long term processes or complex object transformations. It’s returned as a response for operation requests. See Operations oriented part

Example 5. Operation model
{
  "id": "uuid-string",
  "status": "status-string",  (1)
  "parameters": {             (2)
    "inputValue1": 42
  },
  "result": {                 (3)
    "updatedValue1": "42"
  }
}
  1. one of set of statuses agreed in your team

  2. input operation parameters

  3. the result of operation. May contain intermediate state.

Data & Transfer conventions

  • URI path must use 'kebab-case'

  • URI path entries are used in plural form, e.g. /comments/{id}

  • Field naming convention must be either 'snake_case' or 'camelCase' (but the same across the whole application)

  • Entity cannot contain another Entity, i.e. nesting isn’t allowed

  • Arrays inside Entity can contain only primitives (numbers, chars, strings) or structures

  • Date & time are always in UTC and have ISO 86013 format

This part describes CRUD calls on Entities. All operations usw the same Entity model (except Views)

Entity addressing

Common rule to address entity is <entity name in plural>/<optional entity identity>

books/42
comments/5da69dfa-055f-11e9-8eb2-f2801f1b9fd1

Entity address cannot contain more than one identity, in other words nested addresses are prohibited

right: /comments/{comment-id}
wrong: /posts/{post-id}/comments/{comment-id}

Handling Entities

C4S is using subset of HTTP verbs to manipulate an Entity

Create

POST request with body on entity path

Example 6. Request/Response of creation

Request POST example.org/api/comments

{
  "threadId": 42,
  "content": "42"
}

Response:

{
  "id": 42,
  "threadId": 42,
  "content": "42"
}

Read by Identity

GET request on entity path with Identity

Example 7. Request/Response Entity by Identity

Request GET example.org/api/comments/42

Response:

{
  "id": 42,
  "threadId": 42,
  "content": "42"
}

Update

PUT request with body on entity path. Given entity will override previous one. Not passed field means unset (or set the null value)

Example 8. Request/Response of update

Suppose we have profile Entity like this

{
  "userId": 42,
  "birthDay": "1970-01-01",
  "firstName": "Joni",
  "middleName": "Jerry",
  "lastName": "Doe"
}

Request PUT example.org/api/comments

{
  "userId": 42,
  "birthDay": "1970-01-01",
  "firstName": "John",
  "lastName": "Doe"
}

Response:

{
  "userId": 42,
  "birthDay": "1970-01-01",
  "firstName": "John",
  "lastName": "Doe"
}

The first name was changed and middle name was unset (removed)

Delete

DELETE request on entity path with Identity

Example 9. Entity removing Request/Response

Request DELETE example.org/api/comments/42

Response:

204 status-code [No Content] in case of success

Read list of Entities

GET request on entity path returns Filtrator

Example 10. Filtrator Request/Response

Request GET example.org/api/comments

Response:

{
  "items": [
    {
      "id": 42,
      "threadId": 42,
      "content": "new message"
    }
  ],
  "hasMore": false,
  "filter" : {
    "limit" : 20
  }
}

Setup relations between Entities

The documentation bellow operates such definitions like first (was created 'before'), second (was created 'after'), one and many which describes corresponding parts of relation types.

One-to-One

POST request to the second entity with Identity of the first in a body.

POST request example.org/api/profiles

{
  "userId": 42,
  "birthDay": "1970-01-01"
}

Response:

{
  "id": 43,
  "userId": 42,
  "birthDay": "1970-01-01"
}
Note
If bidirectional link is required (to filter the first by identity of the second) it is allowed to set identity of the second into the first entity implicitly during the operation.

One-to-Many

POST request to the many entity with Identity of the one in a body.

POST request example.org/api/likes

{
  "commentId": 42,
  "type": "positive"
}

Response:

{
  "id": 43,
  "commentId": 42,
  "type": "positive"
}
Note
If bidirectional links are required it is allowed to append identity of many to the array in one entity implicitly during the operation.

You may notice that process of connecting One-to-One and One-to-Many are quite similar.

Many-to-Many

This type of relation is difficult to manage and filter. Try to avoid this case in resource model by hiding behind "One-to-Many" if really need to.

Views for Entities

Views are useful for extending (with additional info) or reducing (to produce lightweight representation) Entities. View of an Entity may me requested with dot extension in a path, e.g. <entity-path>.<view>

Example 11. Views

/comments/42.lite
/comments.with-likes-count

View can be created for particular cases or be generic like <entity>.count which adds total amount to Filtrator response. Filters are also applicable to views like to entities.

Example 12. Filters on Views

GET request example.org/api/comments.count?createdAt-lte=1970-01-01

Response:

{
  "items": [],
  "hasMore": false,
  "filter" : {
    "createdAt-lte": "1970-01-01",
    "limit": 20
  },
  "count": 0
}

Operations oriented part

This path describes operations (or remote procedure calls) on the entities.

Operations are designed for long term calls or transformations which cannot be done on entity like an object. All calls (except create) use the Operation model. The creation uses form with input parameters which are individual for every operation.

Operation addressing

Because entity path may have long prefix (due to routeing rules), operation URI part in a path it’s separated from object part by token /-/ (means: not an Identity)

Example 13. Operation path

/prefix/entity/-/operation
/api/microservice/v42/entity/-/make-backup

Operation URI path can contain only operation identity, and all parameters must be passed in a body.

Scheme of operation URI:

/<any-server-prefix>/<entity>/-/<operation>/<operation-id>

Handling Operations

Create

POST request with body on operation path to create/start. This call returns object of operation instead of sent parameters

Example 14. Start archiving operation

Request POST example.org/api/comments/-/archive

{
  "threadId": 42
}

Response:

{
  "id": "5725fb91-755e-44ca-877b-d633a128a492",
  "status": "PENDING",
  "parameters": {
    "threadId": 42
  },
  "result": { }
}

Read by Identity

GET request on operation path with Identity

Example 15. Read operation by identity

Request GET example.org/api/comments/-/archive/5725fb91-755e-44ca-877b-d633a128a492

Response:

{
  "id": "5725fb91-755e-44ca-877b-d633a128a492",
  "status": "RUNNING",
  "parameters": {
    "threadId": 42
  },
  "result": { }
}

Abort operation

DELETE request on operation path with Identity

This request should return operation in current state. There are no guarantees about immediate aborting (or rollback) because it depends on the server implementation. This is a way just say to server that the result of its operations already doesn’t matter for client.

Example 16. Sending of abort signal

Request DELETE example.org/api/comments/-/archive/5725fb91-755e-44ca-877b-d633a128a492

Response:

{
  "id": "5725fb91-755e-44ca-877b-d633a128a492",
  "status": "ABORTION"
  "parameters": {
    "threadId": 42
  },
  "result": {
    "archivedCommentIds" : [42]
  }
}
Note
You should consider having a deprecation policy for complete operations and thair results.

Read list of operations

GET request on operation path. Request may contain filters.

Example 17. Getting a Filtrator of operations

Request GET example.org/api/comments/-/archive?status-eq=ABORTED

Response:

{
  "items": [
    {
      "id": "5725fb91-755e-44ca-877b-d633a128a492",
      "status": "ABORTED",
      "parameters": {
        "threadId": 42
      },
      "result": {
        "archivedCommentIds" : [42]
      }
    }
  ],
  "hasMore": false,
  "filter" : {
    "status-eq" : "ABORTED",
    "limit" : 20
  }
}

Sync Views for Operations

Having the same Views for operations like for entities makes no sense. However clients may want to have a synchronous (blocking) request and got the result of operation w/o intermediate states.

For this reason we have a special View for operations: .sync. It says to server that client wants to get the result of operation immediately. Of course, it’s not always possible to do it (for example, if operation is long-term or asynchronous by nature), is such cases server must return error (see Errors processing).

Example 18. Sync View for archiving operation when backend is ready to make it blocking

Request POST example.org/api/comments/-/archive.sync

{
  "threadId": 42
}

Response:

{
  "archivedCommentIds" : [43, 44, 45]
}

Errors processing

To make the convention complete we must define Structure to describe Errors from the server. There can be problems related to the client input (4xx error-codes) or server (5xx error-codes) but all of them must conform the next model:

{
  "status": <integer code>,
  "error": "string error code",
  "requestId": "string uuid"
}

This model is enough for server error codes and can be extended by yourself

Example 19. Response for the 503 error
{
  "status": 503,
  "error": "REMOTE_SERVER_UNAVAILABLE",
  "requestId": "054a71e0-0cfd-11e9-ab14-d663bd873d93"
}
Example 20. Extended model for the 4xx errors
{
  "status": 400,
  "error": "BAD_REQUEST",
  "requestId": "28f828da-0cfd-11e9-ab14-d663bd873d93",

  "description": "Several constraint over the entity was violated",
  "fields": {
    "comment": {
      "error": "CONTENT_IS_TOO_LONG",
      "description": "Comment message is too long",
    },
    "title": {
      "error": "CONTENT_IS_TOO_SHORT",
      "description": "title must have at least 5 symbols",
      "parameters": {
        "minLength" : 5
      }
    }
  }
}

Or keep it as simple as possible

{
  "status": 409,
  "error": "OPERATION_IS_ALREADY_ABORTED",
  "requestId": "63de8a9c-0cfe-11e9-ab14-d663bd873d93"
}

Best Practices

Nested structures to avoid duplication

{ //...
  "homeAddress": {
    "street": "Aviation",
    "building": "1"
  }
}

instead of:

{ //...
  "homeAddressStreet": "Aviation",
  "homeAddressBuilding": "1"
}

Implicit filters to reduce unnecessary server loads

You can add default limit for Filtrator queries:

Request GET example.org/api/comments

Response:

{
  "items": [
    {
      "id": 42,
      "threadId": 42,
      "content": "42"
    }
  ],
  "hasMore": false,
  "filter" : {
    "limit" : 20 // implicit filter should be always returned
                 // with response to avoid confusion
  }
}

Better than Pagination

The concept of Filtrators is created to overcome drawbacks of classic Pagination. When you are using pagination you provide the limits (a page number and number of items on the page) and orders as filters. Such functionality is easy but can lead to duplication due to prepended entities, so it can lead to expensive count queries to count total amount of pages.

On hot database tables the consistent count queries are not easy tasks but the result often is not so important for users. Instead of this we suggest to use infinity scrolling or powerful filtration system (all counts can be counted on demand if they really needed)

Example 21. Side by Side comparison

Pagination request GET …​/comments?page=0&limit=20

Response:

{
  "content": [{...},{...},...],
  "page": 0,
  "limit": 20,
  "totalPages": 42
}

Filtration request GET …​/comments?id-gte=0&limit=20

Response:

{
  "items": [{},{},],
  "hasMore": true,
  "filter" : {
    "id-gte": "0",
    "limit" : 20
  }
}

So as you can see for the first page changes are pretty simple.

Suppose we have serial Identities for our entities. To navigate to the following results use next queries

Example 22. Side by Side navigation

Pagination request GET …​/comments?page=1&limit=20

Response:

{
  "content": [{...},{...},...],
  "page": 1,
  "limit": 20,
  "totalPages": 42
}

Filtration request GET …​/comments?id-gt=42&limit=20 where 42 is the Identity of the last item of previous response

Response:

{
  "items": [{},{},],
  "hasMore": true,
  "filter" : {
    "id-gt": "42",
    "limit" : 20
  }
}

But the real power appears when you want navigation by date, priority, name or whatever you Entity can hold. With Filtrators you are safe from pre- or appending new items regardless of order.

Short-living operation for non-object transformations

If you want to make non-object related change, let’s say update amount of votes for the comment, you should use Operation. Such operations can use Sync View and provide final result as response.

Example 23. Short-living operation to make a like

Suppose we have comment with 5 votes:

{
  "id": 42,
  "threadId": 42,
  "content": "popular comment",
  "votes": 5
}

Because comments can be voted simultaneously by many users we cannot set new value. We use operation increment on this Entity which check constraints (like one like from one user) and add 1 vote. Developers should decide what the result payload of operation will be returned. In this case we decide return the current amount of votes.

Request POST example.org/api/comments/-/upvote.sync

Response:

{
  "id": "5725fb91-755e-44ca-877b-d633a128a492",
  "status": "DONE",
  "parameters": {},
  "result": {
    "votes": 7
  }
}

The values 7 in this case means someone has upvoted comment between initial page load and voting for comment by us.

With time a database will accumulate many such Short-living operations so there can be deprecation policy to remove them.