REST URL design - An opinionated list of best practices

16 min read

REST APIs come in all flavors. A quick review of all APIs provided from twitter, facebook, google, twillio and other companies make it clear that there is no single standard. You can checkout my other post on why REST APIs are outdated.

This article is a list of practical recommendations when developing new REST APIs design. Consider them as an oppunionated design guidance. There is no right and wrong, but pick one and stick with it.

Story line

Item 1:    Use only GET & POST

HTTP and REST spec provide a variety of request verbs like GET, POST, PATCH, PUT and DELETE.

If you control your callers, stick to only GET and POST HTTP methods. This is true when all the callers are internal company products. Let us look at each of the HTTP verbs and understand why I suggest you to stay away from them.

  • PUT
    • This verb puts too much knowledge of internals to the client. The client needs to reconstruct the exact payload with all the relationships which will force the client to understand all the foreign key relationships.

    • Imagine you have a restaurant table backing a /restaurant object. A PUT during a name update will force you to understand the menu items foreignkeys.

  • PATCH
    • Absolutely no reason to know whether it was a partial update or a full update. Infact sometimes partial updates might put your backend into a logically inconsistent state.
    • Imagine the restaurant patch updates the name, but new restaurant creation via POST has validationst that are not replicated into the PATCH API.
    • Not all frameworks implement this verb correctly. Here is an old issue from the laravel framework community. If your company is developing software on new languages or frameworks, it is better to stick with commonly used verbs.
  • DELETE
    • This verb does not support passing data via body. This forces all parameters to be passed through query parameters and query parameters can sometimes be insufficient. This will result in some of your APIs using DELETE while others default back to POST resulting in inconsistencies.

Item 2:    Use PATCH, DELETE & PUT only if

If your company actively sells APIs for integrations to external vendors into your product, try to support all HTTP verbs. You may still choose to convert incoming requests to a POST before your serving layer.

Not doing so will result in Cross Vendor integrations issues as we see in the support ticket.

Item 3:    Casing in the url

There are hand full of casing used by various companies and there appears to be no true consensus. I do recommend picking one and sticking with it.

  • /underscores_between_urls
  • /camelCase
  • Special characters outside of [0-9][a-z]
  • Do not use upper case in the URL (using them in query is fine)
Some industry usages

Item 4:    Do not have /deeply/nested/url/paths

Using pure REST semantics might force some APIs to be really nested.

  • Max depth 2
    • GET or POST /area/1/restaurant/5
    • GET or POST /menu/6/menuitem/4
    • POST /menu-editor/update-menu
  • Browser urls and proxies allow a max length. Set a smaller value and Respond with a 414 statuscode.
  • /area/1/restaurant/5/menu/6/menuitem/4

Item 5:    Boolean in query

There are many ways to represent boolean as string, numbers in query, params and body. It is critical to pick one and stick with it.

  • use “true”, “false” in query - /?active=true
  • use “true”, “false” in body - {“active”: true}
  • “0” & “1” (string) - /?active=1
  • 0 & 1 (int) - {“active”: 1}

Item 6:    Arrays in query

There are times when you would want to pass an array to the server using a GET request. Pick one to stick with it.

  • /?key[]=1&key[]=2
  • /?keys[]=1&keys[]=2 (note plural)
  • /?key=1&key=2
  • /?key=1,2
  • /?key=[1,2]

Item 7:    Hash in query

Although relatively rare, there are times when you would want to pass a dictionary to the server using a GET request. Pick one to stick with it.

  • /person?details.age=5&details.gender=male
  • /person?details[age]=5&details[gender]=male
  • url encoded string - /person?hash=details%5Bage%5D%3D5
  • multi level nesting - details.age.birthyear

Item 8:    Batch API vs Single API

Very often your APIs start with returning a single item and operating on a single item. As performance bottlenecks arise, there will be a need for a bulk api. If you stick with this plan, you will not have to double implement a shim everytime you need a bulk operation on an existing API.

  • Collection - /menu-items
  • Getting a single Resource - /menu-items/a
  • /menu/remove-items?id[]=20&id[]=30
  • Collection - /bulk-menu-items or /all-menu-items
  • Getting a single resource with - /menu-item
  • /menu/remove-item?id[]=20&id[]=30 (note singlar name remove-item)

Item 9:    + in query params

”+” is a special character that can be used in query. When used, your query will replace the “+” for a “space” when the query is read.

  • /books?q=a+5 server reads as “a 5”
  • /books?q=a+++++5 server reads as “a     5”

This will be handy in cases like sort, as we will see in the next item.

Item 10:    Sort

Across all APIs use a standard query, body field name for sorting operations. Use the DESC and ASC keywords.

  • /books?sort[]=title+DESC&sort[]=author+ASC
  • /menu?sort=title&desc=title or ?sort=title&asc=author
  • /menu?sortby=itemname

Item 11:  Pagination

Almost all APIs returning a list needs pagination once the length of the return data is more than a few hundreds.

  • /menuitems?offset=5&limit=10
  • /menuitems?page[number]=5&page[size]=10
  • Doing the pagination through headers
    • Content-Range
    • Accept-Range

Item 12:    filter & exclude

A number of times you would want to filter (select) or exclude (unselect) the response data based on certain parameters. In those cases, reserve special query parameters.

  • /menu-items?filter=key:value
  • /menu-items?filter=ingredient:tomatoes
  • /menu-items?filter[]=ingredient:tomatoes&filter[]=size:xl

There are cases where you will have to use lte, gte to represent ‘less than equal’ and ‘greater than equal’. In those cases the following is recommended.

  • /menu-items?filter=key_lte:value
  • /menu-items?filter=price_lte:117
  • /menu-items?filter=price_gte:117

Item 13:    filter & exclude with logic

There are also caess where we will need boolean operations for our filters. In those cases we will have to introduce boolean operations into the filter query.

Logic Query
A && B filter[]=key:A&filter[]=key_and:B
A || B filter[]=key:A&filter[]=key_or:B
A || B && C filter[]=key:A&filter[]=key_or:B& filter[]=key_and:B
A && B || C && D filter[]=key:A&filter[]=key_and:B& filter[]=key_or:C&filter[]=key_and:D

The order of resolution should follow a precendence order. An operation like A && B || C && D should be evaluated as (A && B) || (C && D)

  • A complex long filter and exclude combination. In those cases consider using search API.

Item 14:    Advanced /search is different

A lot many times filter and sort are used to do complex searching operations via get API. This can get complex quickly.

  • Use POST for all searches
  • Cache the results in the backend based on query hash
  • Allow for accessing query result using GET with a query hash
  • GET for complex /search?itemname=salad&area=california&more
  • ?q=value&scope=value
  • GET https://domain.com/e/?q=value&scope=menu
  • POST https://domain.com/?q=value&scope=menu use item 10 instead

Item 16:    Reserved URLs

  • /health/*
  • /debug/*
  • /metrics/*
  • /status/*

Item 17:    Content type in URL

Different clients would require different response types, e.g, HTML, JSON APIs, binary reponse, XML. The client should be able to specify the response data it would need.

  • use the content-type header to specify if you want xml or json
  • adding it to the url like /device-management/managed-devices.xml
  • adding it to the url like /device-management/managed-devices.json
  • adding it to the url like /device-management/managed-devices.html

Item 18:    Versioning

An API has versioning across two dimensions

Logic

A change where /menu-editor/update-menu restricts updating the menu of archived items, but it used to allow it for previous version of the mobile apps (that are still used by customers).

Schema

A change where /menu-editor/update-menu now optionally takes another value “rating” for each menu item.

Logic and schema can be a “breaking change” or a “non breaking change” and should be treated differently when versioning.

  • Attempt to remain backward compatible in logic and schema.
  • Non-breaking changes (rarely used)
    • Send version in a separate header if needed. It might be optionally respected.
    • X-COMPANY-API-MINOR-VERSION:1.5
  • Breaking change for internal APIs
    • Add a new version to the path of the API being changed
      • /v3/menu-editor/update-menu
      • /v4/menu-editor/menu-items
  • Breaking change for external APIs
    • Accumulate changes into a single upgrade for all APIs
      • /v3/menu-editor/update-menu
      • /v4/menu-editor/menu-items
      • /v4/menu-editor/update-menu
  • Accept: application/vnd.megacorp.bookings+json; version=1.0
  • /menu-editor/update-menu?version=v3
  • /v3/menu-editor/v1/update-menu

Item 19:    Provide “pretty” option

With JSON APIs a developer would be accessing your API during development.

  • support ?pretty=true to return non minified JSON response.

Item 20:    Naming your resources

Naming resources is one of the most complex topics of URL design.

  • Map APIs to business operations and not tables
    • You should convert /area/1/restaurant/5/menu/6/menuitem/4 into a logical operation of “menu-editor”.
    • Use /menu-editor/update-menu instead
  • Use singular names in path
    • Plural can sometimes become confusing. Here is an example /goose vs /geese.
  • Don’t fret using HTTP verbs in url
    • it is ok to use /get-restaurant
    • POST /delete-restaurant is fine too
  • Map APIs 100% to your normalized table names.
    • keeping it normalized will make client business heavy (resulting in tight coupling). A client will then have to understand the exact relationship between restaurant and a menuitem to orchestrate them.
    • Chattiness between client & server increases with normalization
    • Transaction across APIs will not always succeed and will leave the backend in inconsistent state.

Practical Example applying all items

Imagine you have a food ordering website and you are building rest APIs for it.

Tables: User, Account, PaymentAccount, Payment, SubscriptionType, Menu, MenuItem, MenuMenuItem, SubscriptionTypeMenuMenuItem, Cart, CartItem, CartItemMenuMenuItem, Restaurant, RestaurantAccount, RestaurantMenu

Step 1: Write down the top 5 interactions a customer does with this system
  • Signup and Add payment
  • Create a new cart and add menu items
  • Update items on a card
  • Checkout
Step 2: Refine and consolidate
  • Account setup (signup, add payments)
  • Shopping (create a cart and add menu items)
  • Configure menu (creates menu, edit menu items)
Step 3: Finalize root URL paths
  • /account-setup/
  • /shopping/
  • /menu/
  • /menu-editor/
  • /restaurant/
Step 4: Provide shortcuts for many to many assignments
  • /menu/menu-items/:menuItem/restaurants
  • /restaurant/:restaurantId/menu-item
Step 5: Convert similar urls to filter, sort and exclude
  • /get-highest-rating-restaurants becomes
    • /restaurant/?sort=rating+DESC&limit=10
  • from: /get-lowest-rating-restaurants
    • to: /restaurant/?sort=rating+ASC&limit=10
  • /get-all-restaurants
    • /restaurant
  • /get-chain-resturants
    • /restaurant?filter=type:chain
  • /get-restaurant-owned-by-mcdonalds
    • /restaurant?alias=mcdonalds
  • Step 6: Alias common queries
    • /get-restaurants-closed-last-month
      • /restaurant?alias=closed-last-montly

Conclusion

Getting everything right is always hard. Do understand that REST is going to be as close as possible, but be practical. Above everything else remain consistent and your API will be pleasant to use in the long run.