Up till now, when designing/coding API endpoints for resources, I would have separate API endpoints for working on a single record and on multiple records, e.g. /api/actor/123/edit
(with record ID set as path parameter) and /api/actor/edit
(with record IDs set in POST request body) for the “actor” resource.
This would inevitably result in duplication of business/validation logic or creation of a common function with that logic which would be called by both types of endpoints. The methods in the table gateway used to fetch the records would be different, e.g. fetch()
for a single record and fetchAll()
for multiple records, which in turn would be considered duplication and introduce variances such as handling of SQL parameters.
This affects permissions as well, as there is a need to check if the user has “edit” permission to edit a record when calling /api/actor/123/edit
, or if the user has “edit-multiple” permission to edit multiple records when calling /api/actor/edit
(and maybe check “edit” permission for each record as well).
After some thought, felt that it would be simpler and more efficient to only have API endpoints for working on multiple records, since a for loop can be used to process an array of records and that a single record can be represented as an array containing 1 record. As for permissions, it would suffice to just check if the user has the “edit” permission for the resource.
The table below shows the suggested API endpoints using the naming convention /api/<resource>/<action>
, where resource
is a noun and action
is a verb. A few things to note:
-
Regardless of whether an API endpoint is being used for a single record or for multiple records, the API response should be the same, e.g. the
list
API endpoint should return an array of records with pagination even if it is used for a single record (see this article):{ "data": { "items": [ { "id": 123, "name": "a" } ] }, "error": null, "meta": { "version": "v0.1.0-develop-abcd123-20251022T0830Z", "pagination": { "items_per_page": 10, "page": 1, "total_items": 1, "total_pages": 1 } } }
- The word “actor” is synonymous with “user” and would typically correspond to a table with the same name in the database. The words “user” and “account” are not used as they are reserved words in SQL, as per MySQL and PostgreSQL docs.
- Singular forms are used for resource names instead of plural forms, e.g. “actor” instead of “actors”. This reduces the effort in reconciling plural forms in code and user interface, especially with irregular plural forms such as child/children, company/companies, news/news, etc.
-
While the record IDs can be specified via the POST request body, it is recommended that they be specified via querystring params to aid troubleshooting when viewing request URLs in access/audit logs where the POST request body may not be logged (in the event that the record IDs are specified via query param as well as the POST request body, use the query param so as to be consistent with the request URL in the logs). The
count
query param for theadd
API endpoint serves the same purpose of aiding troubleshooting. -
When updating multiple records, top-level parameters in the POST request body can be used for values common to all records, while a
rows
parameter of array type can be used to provide record-specific values. - In the Example column below, “notes” is used internally for staff/developers and not shown to end users, while “remarks” is for end users to use/view. As per this post, remarks are for people reading the document, while notes are reminders for the author of the document.
- The action word “list” is used instead of “view”, considering the dual use of the API endpoint for a listing of all/filtered records, instead of having a separate API endpoint.
- The action word “suspend” is preferred to “disable” as the opposite of the latter is “enable”, which has a less clear correlation compared to “suspend/unsuspend”.
+-----------------------------------------------+------------------------------------------------------+-----------------------------------------------------------------------+ | API endpoint | Description | Example of POST request body | +-----------------------------------------------+------------------------------------------------------+-----------------------------------------------------------------------+ | /api/actor/add?count=1 | Add a record | {"country":"SG","name":"a"} | | /api/actor/add?count=2 | Add multiple records | {"country":"SG","rows":[{"name":"b"},{"name":"c"}]} | | /api/actor/list?ids=123 | List a record, same as /api/actor/123/view | | | /api/actor/list?ids=456,789 | List multiple records | | | /api/actor/list?filter=column:value:operator | List multiple records using filter with pagination | | | /api/actor/edit?ids=123 | Edit a record, same as /api/actor/123/edit | {"country":"SG","name":"a"} | | /api/actor/edit?ids=456,789 | Edit multiple records | {"country":"SG","rows":[{"id":456,"name":"b"},{"id":789,"name":"c"}]} | | /api/actor/delete?ids=123 | Delete a record, same as /api/actor/123/delete | {"notes":"a"} | | /api/actor/delete?ids=456,789 | Delete multiple records | {"rows":[{"id":456,"notes":"b"},{"id":789,"notes":"c"}]} | | /api/actor/undelete?ids=123 | Undelete a record, same as /api/actor/123/undelete | | | /api/actor/undelete?ids=456,789 | Undelete multiple records | | | /api/actor/suspend?ids=123 | Suspend a record, same as /api/actor/123/suspend | {"remarks":"x"} | | /api/actor/suspend?ids=456,789 | Suspend multiple records | {"rows":[{"id":456,"remarks":"y"},{"id":789,"remarks":"z"}]} | | /api/actor/unsuspend?ids=123 | Unsuspend a record, same as /api/actor/123/unsuspend | | | /api/actor/unsuspend?ids=456,789 | Unsuspend multiple records | | +-----------------------------------------------+------------------------------------------------------+-----------------------------------------------------------------------+