20 Rules of Designing Program Interfaces

// Cancels an order
GET /orders/cancellation
// Cancels an order
POST /orders/cancel
// Returns aggregated statistics
// since the beginning of time
GET /orders/statistics
// Returns aggregated statistics
// for a specified period of time
POST /v1/orders/statistics/aggregate
{ "begin_date", "end_date" }
// Returns a pointer to the first occurrence
// in str1 of any of the characters
// that are part of str2
strpbrk (str1, str2)
// Returns a list of coffee machine builtin functions
GET /coffee-machines/{id}/functions
// Find the position of the first occurrence
// of a substring in a string
strpos(haystack, needle)
// Replace all occurrences
// of the search string with the replacement string
str_replace(needle, replace, haystack)
  • inconsistent underscore using;
  • functionally close methods have different needle/haystack argument order;
  • first function finds the first occurrence while second one finds them all, and there is no way to deduce that fact out of the function signatures.
// Creates an order and returns its id
POST /v1/orders
{ … }

{ "order_id" }
// Returns an order by its id
GET /v1/orders/{id}
// The order isn't confirmed
// and awaits checking
→ 404 Not Found
  • clients might lose the id, if system failure happened in between sending the request and getting the response, or if app data storage was damaged or cleansed;
  • customers can’t use another device; in fact, the knowledge of orders being created is bound to a specific user agent.
// Creates an order and returns it
POST /v1/orders
{ <order parameters> }

{
"order_id",
// The order is created in explicit
// «checking» status
"status": "checking",

}
// Returns an order by its id
GET /v1/orders/{id}

{ "order_id", "status" … }
// Returns all customer's orders
// in all statuses
GET /v1/users/{id}/orders
GET /coffee-machines/{id}/stocks

{
"has_beans": true,
"has_cup": true
}
{
"beans_absence": false,
"cup_absence": false
}
POST /v1/orders
{}

{
"contactless_delivery": true
}
if (Type(order.contactless_delivery) == 'Boolean' &&
order.contactless_delivery == false) { … }
POST /v1/orders
{}

{
"force_contact_delivery": false
}
// Creates a user
POST /users
{ … }

// Users are created with a monthly
// spending limit set by default
{

"spending_monthly_limit_usd": "100"
}
// To cancel the limit null value is used
POST /users
{

"spending_monthly_limit_usd": null
}
POST /users
{
// true — user explicitly cancels
// monthly spending limit
// false — limit isn't canceled
// (default value)
"abolish_spending_limit": false,
// Non-required field
// Only present if the previous flag
// is set to false
"spending_monthly_limit_usd": "100",

}
// Return the order state
// by its id
GET /v1/orders/123

{
"order_id",
"delivery_address",
"client_phone_number",
"client_phone_number_ext",
"updated_at"
}
// Partially rewrites the order
PATCH /v1/orders/123
{ "delivery_address" }

{ "delivery_address" }
  • no data pagination is provided;
  • no limits on field values are set;
  • binary data is transmitted (graphics, audio, video, etc.)
  • making separate endpoints for ‘heavy’ data;
  • introducing pagination and field value length limits;
  • stopping saving bytes in all other cases.
// Return the order state
// by its id
GET /v1/orders/123

{
"order_id",
"delivery_details": {
"address"
},
"client_details": {
"phone_number",
"phone_number_ext"
},
"updated_at"
}
// Fully rewrite order delivery options
PUT /v1/orders/123/delivery-details
{ "address" }
// Fully rewrite order customer data
PUT /v1/orders/123/client-details
{ "phone_number" }
POST /v1/order/changes
X-Idempotency-Token: <see next paragraph>
{
"changes": [{
"type": "set",
"field": "delivery_address",
"value": <new value>
}, {
"type": "unset",
"field": "client_phone_number_ext"
}]
}
// Creates an order
POST /orders
// Creates an order
POST /v1/orders
X-Idempotency-Token: <random string>
// Creates order draft
POST /v1/orders/drafts

{ "draft_id" }
// Confirms the draft
PUT /v1/orders/drafts/{draft_id}
{ "confirmed": true }
  • a client didn’t get the response because of some network issues, and is now repeating the request;
  • a client’s mistaken, trying to make conflicting changes.
POST /resource/updates
{
"resource_revision": 123
"updates"
}
POST /resource/updates
X-Idempotency-Token: <token>
{
"resource_revision": 123
"updates"
}
→ 201 Created
POST /resource/updates
X-Idempotency-Token: <token>
{
"resource_revision": 123
"updates"
}
→ 409 Conflict
  • you can’t really expect that clients generate truly random tokens — they may share the same seed or simply use weak algorithms or entropy sources; therefore you must put constraints on token checking: token must be unique to specific user and resource, not globally;
  • clients tend to misunderstand the concept and either generate new tokens each time they repeat the request (which deteriorates the UX, but otherwise healthy) or conversely use one token in several requests (not healthy at all and could lead to catastrophic disasters; another reason to implement the suggestion in the previous clause); writing detailed doc and/or client library is highly recommended.
// Returns a list of recipes
GET /v1/recipes

{
"recipes": [{
"id": "lungo",
"volume": "200ml"
}, {
"id": "latte",
"volume": "300ml"
}]
}
// Changes recipes' parameters
PATCH /v1/recipes
{
"changes": [{
"id": "lungo",
"volume": "300ml"
}, {
"id": "latte",
"volume": "-1ml"
}]
}
→ 400 Bad Request
// Re-reading the list
GET /v1/recipes

{
"recipes": [{
"id": "lungo",
// This value changed
"volume": "300ml"
}, {
"id": "latte",
// and this did not
"volume": "300ml"
}]
}
PATCH /v1/recipes
{
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "-1ml"
}]
}
// You may actually return
// a ‘partial success’ status
// if the protocol allows it
→ 200 OK
{
"changes": [{
"change_id",
"occurred_at",
"recipe_id": "lungo",
"status": "success"
}, {
"change_id",
"occurred_at",
"recipe_id": "latte",
"status": "fail",
"error"
}]
}
  • change_id is a unique identifier of each atomic change;
  • occurred_at is a moment of time when the change was actually applied;
  • error field contains the error data related to the specific change.
  • introducing sequence_id parameters in the request to guarantee execution order and to align item order in response with the requested one;
  • expose a separate /changes-history endpoint for clients to get the history of applied changes even if the app crashed while getting partial success response or there was a network timeout.
PATCH /v1/recipes
{
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "400ml"
}]
}
→ 200 OK
{
"changes": [{

"status": "success"
}, {

"status": "fail",
"error": {
"reason": "too_many_requests"
}
}]
}
PATCH /v1/recipes
{
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "400ml"
}]
}
→ 200 OK
{
"changes": [{

"status": "success"
}, {

"status": "success",
}]
}
// Returns lungo price in cafes
// closest to the specified location
GET /price?recipe=lungo
&longitude={longitude}&latitude={latitude}

{ "currency_code", "price" }
  • until when the price is valid?
  • in what vicinity of the location the price is valid?
// Returns an offer: for what money sum
// our service commits to make a lungo
GET /price?recipe=lungo
&longitude={longitude}&latitude={latitude}

{
"offer": {
"id",
"currency_code",
"price",
"conditions": {
// Until when the price is valid
"valid_until",
// What vicinity the price is valid within
// * city
// * geographical object
// * …
"valid_within"
}
}
}
// Returns a limited number of records
// sorted by creation date
// starting with a record with an index
// equals to `offset`
GET /v1/records?limit=10&offset=100
  1. How clients could learn about new records being added in the beginning of the list? Obviously a client could only retry the initial request (offset=0) and compare identifiers to those it already knows. But what if the number of new records exceeds the limit? Imagine the situation:
  • the client process records sequentially;
  • some problem occurred, and a batch of new records awaits processing;
  • the client requests new records (offset=0) but can't find any known records on the first page;
  • the client continues iterating over records, page by page, until it finds the last known identifier; all this time the order processing is idle;
  • the client might never start processing, being preoccupied with chaotic page requests to restore records sequence.
  1. What happens if some record is deleted from the head of the list?
    Easy: the client will miss one record and will never learn this.
  2. What cache parameters to set for this endpoint?
    None could be set: repeating the request with the same limit and offset each time produces new records set.
// Returns a limited number of records
// sorted by creation date
// starting with a record with an identifier
// following the specified one
GET /v1/records?older_than={record_id}&limit=10
// Returns a limited number of records
// sorted by creation date
// starting with a record with an identifier
// preceding the specified one
GET /v1/records?newer_than={record_id}&limit=10
// Initial data request
POST /v1/records/list
{
// Some additional filtering options
"filter": {
"category": "some_category",
"created_date": {
"older_than": "2020-12-07"
}
}
}

{
"cursor"
}
// Follow-up requests
GET /v1/records?cursor=<cursor value>
{ "records", "cursor" }
  • such a case (pages list and page selection) exists if we deal with user interfaces; we could hardly imagine a program interface which needs to provide an access to random data pages;
  • if we still talk about an API to some application, which has a ‘paging’ user control, then a proper approach would be to prepare ‘paging’ data on server, including generating links to pages;
  • cursor-based solution doesn’t prohibit using offset/limit; nothing could stop us from creating a dual interface, which might serve both GET /items?cursor=… and GET /items?offset=…&limit=… requests;
  • finally, if there is a necessity to provide an access to arbitrary pages in user interface, we should ask ourselves a question, which problem is being solved that way; probably, users use this functionality to find something: a specific element on the list, or the position they ended while working with the list last time; probably, we should provide more convenient controls to solve those tasks than accessing data pages by their indexes.
// Returns a limited number of records
// sorted by a specified field in a specified order
// starting with a record with an index
// equals to `offset`
GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=100
// Creates a view based on the parameters passed
POST /v1/record-views
{
sort_by: [
{ "field": "date_modified", "order": "desc" }
]
}

{ "id", "cursor" }
// Returns a portion of the view
GET /v1/record-views/{id}?cursor={cursor}
POST /v1/records/modified/list
{
// Optional
"cursor"
}

{
"modified": [
{ "date", "record_id" }
],
"cursor"
}
POST /v1/coffee-machines/search
{
"recipes": ["lngo"],
"position": {
"latitude": 110,
"longitude": 55
}
}
→ 400 Bad Request
{}
{
"reason": "wrong_parameter_value",
"localized_message":
"Something is wrong. Contact the developer of the app."
"details": {
"checks_failed": [
{
"field": "recipe",
"error_type": "wrong_value",
"message":
"Unknown value: 'lngo'. Did you mean 'lungo'?"
},
{
"field": "position.latitude",
"error_type": "constraint_violation",
"constraints": {
"min": -90,
"max": 90
},
"message":
"'position.latitude' value must fall within [-90, 90] interval"
}
]
}
}
POST /v1/orders
{
"recipe": "lngo",
"offer"
}
→ 409 Conflict
{
"reason": "offer_expired"
}
// Request repeats
// with the renewed offer
POST /v1/orders
{
"recipe": "lngo",
"offer"
}
→ 400 Bad Request
{
"reason": "recipe_unknown"
}
POST /v1/orders
{
"items": [{ "item_id": "123", "price": "0.10" }]
}

409 Conflict
{
"reason": "price_changed",
"details": [{ "item_id": "123", "actual_price": "0.20" }]
}
// Request repeats
// with an actual price
POST /v1/orders
{
"items": [{ "item_id": "123", "price": "0.20" }]
}

409 Conflict
{
"reason": "order_limit_exceeded",
"localized_message": "Order limit exceeded"
}
// Create an order
// with a payed delivery
POST /v1/orders
{
"items": 3,
"item_price": "3000.00"
"currency_code": "MNT",
"delivery_fee": "1000.00",
"total": "10000.00"
}
→ 409 Conflict
// Error: if the order sum
// is more than 9000 tögrögs,
// delivery must be free
{
"reason": "delivery_is_free"
}
// Create an order
// with a free delivery
POST /v1/orders
{
"items": 3,
"item_price": "3000.00"
"currency_code": "MNT",
"delivery_fee": "0.00",
"total": "9000.00"
}
→ 409 Conflict
// Error: munimal order sum
// is 10000 tögrögs
{
"reason": "below_minimal_sum",
"currency_code": "MNT",
"minimal_sum": "10000.00"
}
POST /search
{
"query": "lungo",
"location": <customer's location>
}
→ 404 Not Found
{
"localized_message":
"No one makes lungo nearby"
}
POST /search
{
"query": "lungo",
"location": <customer's location>
}
→ 200 OK
{
"results": []
}

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

3 Practices that will enhance your Software Engineering skills

Scrum Master Anti-Patterns

Building a Safe Multi-Tenant System With Rate Limiting

Serverless Networking is the next step in the evolution of serverless

Data Verification —Data Lake & Database Migration Projects

Digging into Data Science Tools: Docker

Contextless technical advice no more

Microservice — How & Why

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Sergey Konstantinov

Sergey Konstantinov

The API Guy

More from Medium

Relational database (MySql)

State design Pattern: (Video call case study)

Why is MySQL skipping the index?

Swagger — All you need to start