IPA-102: Resource Identifiers
Most APIs expose resources (their primary nouns) that users can create, retrieve, and manipulate. Additionally, resources are named meaning each resource has a unique identifier that API consumers use to reference that resource.
Guidance
The full resource identifier is a schemeless URI: a fully qualified path with no transport protocol. The guidelines below cover how those identifiers are structured, named, and related to one another.
All resource identifiers defined by an API must be unique, with resource names formatted according to the URI path schema.
paths:
/users/{userId}:
get:
operationId: getUser
/users/{userId}/tasks/{taskId}:
get:
operationId: getUserTaskWhy:Each path resolves to one resource.
/users/{userId}addresses a single user, and the nested task path addresses a single task under that user. No two paths point at the same thing.paths:
/users/{userId}:
get:
operationId: getUser
/users/{id}:
delete:
operationId: deleteUserWhy:Both paths address the same resource, a user keyed by its id, but the parameter name differs. That is one identifier written two ways. Collapse them into a single path with one parameter name.
List every key under
$.paths. Normalize each one by replacing path parameters with a placeholder. For example, treat/users/{userId}and/users/{id}both as/users/{}.Compare the normalized forms. If two distinct path keys normalize to the same string, they address the same resource and violate uniqueness. Flag them.
Check that each path follows the URI path schema: segments separated by single slashes, no scheme or host, no query string baked into the path. A path that embeds a query or a protocol is not a valid resource identifier.
Resource identifiers must use the slash (/) character to separate individual segments of the resource identifier.
paths:
/teams/{teamId}/members:
get:
operationId: listTeamMembers
/teams/{teamId}/members/{memberId}:
get:
operationId: getTeamMemberWhy:Every boundary uses a single slash, so the hierarchy is obvious: a
memberscollection nested under a specific team.paths:
/teams/{teamId}//members/{memberId}:
get:
operationId: getTeamMemberWhy:The double slash between
{teamId}andmembersleaves an empty segment. That empty segment means nothing, and it breaks path matching in routers and generated clients.- List every path key under
$.paths. For each key, check that a single
/separates each pair of segments.Reject any key that contains
//. A double slash leaves an empty segment.Reject any key where two segment names run together with no slash between them.
- List every path key under
Double slashes (//) must not be used to separate segments of a resource identifier.
paths:
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTaskWhy:Exactly one slash sits between each segment, so there are no empty segments.
paths:
/projects/{projectId}//tasks/{taskId}:
get:
operationId: getTaskWhy:The
//between{projectId}andtasksleaves an empty segment. Use a single slash.Depends onResource identifier components should alternate between collection identifiers and resource IDs.
paths:
/projects/{projectId}:
get:
operationId: getProject
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTaskWhy:Segments alternate collection then ID at every level:
projectsthen{projectId}, thentasksthen{taskId}. Each ID follows the collection it belongs to.paths:
/projects/{projectId}/{taskId}:
get:
operationId: getTaskWhy:Two IDs sit back to back with no collection between them.
{taskId}has no preceding collection segment, so the set it draws from is unclear.Collection identifiers must be in
camelCase.paths:
/users/{userId}:
get:
operationId: getUser
/users/{userId}/projectMembers:
get:
operationId: listProjectMembersWhy:Both
usersandprojectMembersstart lowercase and contain only letters, so they match thecamelCasepattern.paths:
/Users/{userId}:
get:
operationId: getUser
/Users/{userId}/project_members:
get:
operationId: listProjectMembersWhy:Usersstarts with an uppercase letter andproject_memberscontains an underscore. Neither iscamelCase.Collection identifiers must begin with a lowercase letter and contain only ASCII letters and numbers (
/[a-z][a-zA-Z0-9]*/).paths:
/users/{userId}/apiKeys:
get:
operationId: listUserApiKeysWhy:Both
usersandapiKeysstart lowercase and use only ASCII letters and digits.apiKeysiscamelCase, which the pattern allows.paths:
/Users/{userId}/api_keys:
get:
operationId: listUserApiKeysWhy:Usersstarts with an uppercase letter andapi_keyshas an underscore. Neither matches/[a-z][a-zA-Z0-9]*/.Collection identifiers must be plural, except where there is no plural form or the singular and plural terms are the same, in which case the singular form is correct.
paths:
/users/{userId}:
get:
operationId: getUser
/projects/{projectId}/tasks:
get:
operationId: listTasksWhy:users,projects, andtasksare all plural. Each segment names a collection of resources, so the plural form is correct.paths:
/user/{userId}:
get:
operationId: getUser
/project/{projectId}/task:
get:
operationId: listTasksWhy:user,project, andtaskare singular. A collection holds many resources, so each segment should be plural:users,projects,tasks.paths:
/info:
get:
operationId: getInfoWhy:infohas no separate plural form, so the singular is correct. This is the documented exception, not a violation.List the path segments under
$.pathsfor each path. Skip segments wrapped in braces, like{userId}, since those are resource IDs, not collection identifiers.For each remaining segment, decide whether it names a collection of resources. Those are the segments to check for plurality.
Check that each collection identifier is plural. If it is singular, flag it and suggest the plural form.
Before flagging, confirm the noun actually has a distinct plural. If the word has no plural form, or its singular and plural are identical, the singular is correct and you should not flag it.
Resource IDs should be server-generated unique identifiers rather than human-readable, client-provided identifiers, for long-term stability and flexibility.
paths:
/users/{userId}:
get:
operationId: getUser
parameters:
- name: userId
in: path
required: true
schema:
type: string
format: uuid
example: 7c9e6679-7425-40de-944b-e07fc1f90ae7Why:userIdis a server-generated UUID. It is immutable and unique, so the resource URL stays stable for the life of the user even if their email or display name changes.paths:
/users/{email}:
get:
operationId: getUserByEmail
parameters:
- name: email
in: path
required: true
schema:
type: string
format: email
example: jordan@example.comWhy:emailis client-provided and mutable. When a user changes their address, the resource URL changes with it, and every existing reference breaks.For each path in
$.paths, find the last path parameter. That is the one that names the individual resource, for example{userId}in/users/ {userId}.Inspect that parameter's schema. Flag it when the value is a human-readable or client-supplied attribute such as an email, display name, title, or slug rather than an opaque server-generated identifier.
For any flagged parameter, check whether the value is documented as immutable and uniquely constrained. If it can change after creation, the resource URL is unstable and the choice does not meet the recommendation.
A deliberate, documented human-readable identifier that is immutable and unique is an acceptable exception to this
should. An undocumented or mutable one is not.
Resource IDs should follow the format
<resourceName>Id, where<resourceName>is the singular form of the collection identifier.paths:
/projects/{projectId}:
get:
operationId: getProject
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTaskWhy:Each parameter is the singular collection name plus
Id.projectsbecomesprojectId,tasksbecomestaskId.paths:
/projects/{id}:
get:
operationId: getProject
/projects/{projectId}/tasks/{task}:
get:
operationId: getTaskWhy:{id}drops the resource name, so the parameter no longer says what it identifies.{task}leaves off theIdsuffix. Both should readprojectIdandtaskId.Walk each key under
$.pathsand find the path templates: the segments wrapped in braces, like{taskId}.For each path parameter, read the collection identifier that precedes it, the segment just before the templated one.
Make that collection identifier singular and add
Id.tasksshould givetaskId. When a collection has no distinct plural ("info"), use the singular as-is plusId.Flag any parameter that drops the resource name (
{id}), leaves off theIdsuffix ({task}), or uses a name unrelated to its parent collection.
Resource IDs must be in
camelCase.paths:
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTask
parameters:
- name: projectId
in: path
required: true
schema:
type: string
- name: taskId
in: path
required: true
schema:
type: stringWhy:projectIdandtaskIdboth open with a lowercase letter and hold only letters. ValidcamelCaseresource IDs.paths:
/projects/{project_id}/tasks/{TaskID}:
get:
operationId: getTask
parameters:
- name: project_id
in: path
required: true
schema:
type: string
- name: TaskID
in: path
required: true
schema:
type: stringWhy:project_idissnake_caseandTaskIDisPascalCase. A resource ID has to becamelCase, so neither one passes.The resource ID used in the resource identifier (as the URI path parameter) must match the field name used in the resource representation.
paths:
/projects/{projectId}:
get:
operationId: getProject
parameters:
- name: projectId
in: path
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Project"
components:
schemas:
Project:
type: object
properties:
projectId:
type: string
name:
type: stringWhy:The path parameter
projectIdand the representation fieldprojectIdshare one name and one casing, so the identifier reads the same wherever it appears.paths:
/projects/{projectId}:
get:
operationId: getProject
parameters:
- name: projectId
in: path
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Project"
components:
schemas:
Project:
type: object
properties:
id:
type: string
name:
type: stringWhy:The path uses
projectId, but the body exposes the same identifier asid. The two names disagree, so a consumer cannot rely on one key for the resource's ID.- For each path in
$.paths, list the path parameters (the{...}segments) that identify the resource the path addresses. - Find the schema for that resource. It is usually the response body schema of the
GETon the item path. Resolve any$ref. - Confirm the resolved schema declares a property whose name matches the path parameter exactly, casing included.
- Flag any case where the representation names the identifier differently (for example path
{projectId}but fieldid) or omits it entirely.
- For each path in
Resource identifiers should not use abbreviations, unless the abbreviation is well understood (for example, IP, AWS, TCP).
paths:
/organizations/{organizationId}/configurations:
get:
operationId: listConfigurationsWhy:Full words leave no room for doubt. Nobody has to wonder what
organizationsrefers to.paths:
/orgs/{orgId}/cfgs:
get:
operationId: listCfgsWhy:orgsandcfgsare invented shorthands, not abbreviations anyone already knows. The reader has to expand them in their head.paths:
/servers/{serverId}/ipRanges:
get:
operationId: listIpRangesWhy:Everyone knows what
ipmeans, so the short form beatsinternetProtocolRangesfor clarity.- List every path key under
$.pathsand split each one into segments on the slash. - Within each path, separate the collection identifiers from the path parameters (the
{...}parts). Check both. - For each word in a segment, decide whether it is a full word or a shortened form. Shortened forms include dropped vowels (
cfg), truncations (org,addr), and clipped plurals. - For each shortened form, ask whether a typical API consumer already knows it (for example
IP,AWS,TCP,ID,URL,API). If yes, it passes. - Flag any shortened form that does not pass and recommend the spelled-out word.
- List every path key under
Resource identifiers must not include file extensions such as
.gz,.csv, or.json.paths:
/reports/{reportId}:
get:
operationId: getReport
parameters:
- name: Accept
in: header
schema:
type: string
example: application/jsonWhy:The path identifies the report. The caller asks for a representation through the
Acceptheader, so one identifier returns JSON, CSV, or gzip without change.paths:
/reports/{reportId}.csv:
get:
operationId: getReportCsv
/reports/{reportId}.json:
get:
operationId: getReportJsonWhy:The
.csvand.jsonextensions bake the serialization format into the identifier. Now one resource has two identifiers, and every new format wants another path.- Collect every path key under
$.paths. Split each path on
/and check every segment, including the ones that end in a path parameter like{reportId}.Flag any segment whose literal text ends in a dot followed by a format token such as
.json,.csv,.xml,.gz, or.zip.Confirm the suffix is a content format and not a legitimate part of an identifier. If it names a serialization, report it and tell the producer to express the format through the
Acceptheader media type instead.
- Collect every path key under
When a representation format is needed, the file extension must be included only as a media type in the Accept header (for example,
Accept: application/json,Accept: application/gzip), never in the resource identifier.
Nested Collections
When a resource identifier nests one collection under another, the hierarchy itself carries meaning that API producers need to account for.
Relationships between resources expressed as nested collections or hierarchical relationships have certain implications that API producers need to consider.
If a resource identifier contains multiple levels of a hierarchy and a parent collection's name is used as a prefix for the child resource's name, the child collection's name may omit the prefix.
paths:
/projects/{projectId}/members:
get:
operationId: listProjectMembers
/projects/{projectId}/members/{memberId}:
get:
operationId: getProjectMemberWhy:The path already says these members live under a project, so the child collection is just
members. A prefix would only repeat what the path already tells you.paths:
/projects/{projectId}/projectMembers:
get:
operationId: listProjectMembers
/projects/{projectId}/projectMembers/{memberId}:
get:
operationId: getProjectMemberWhy:projectMembersrepeats the parentprojectsthat already sits right in front of it in the path. Theprojectprefix is noise.In
$.paths, find paths with multiple collection segments, where a child collection nests under a parent collection (for example/parents/ {parentId}/children).For each child collection segment, check whether its name starts with the singular or plural form of the parent collection that comes right before it in the path (for example
projectMembersunderprojects).When the child name carries that parent prefix, treat it as a candidate for the allowance. The prefix may be dropped so the segment becomes just the child noun (
members).Before you recommend the change, confirm the un-prefixed name stays clear in context. This rule is permissive, so don't flag a kept prefix as a violation. Call out redundant prefixes only as a chance to simplify.
Deleting a parent resource must delete its associated child resources (nested collections imply a cascade effect).
paths:
/projects/{projectId}:
delete:
operationId: deleteProject
description: >
Deletes the project and all tasks nested under it.
parameters:
- name: projectId
in: path
required: true
schema:
type: string
responses:
"204":
description: Project and its tasks were deleted.
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTaskWhy:tasksis nested underprojects. Deleting a project removes the tasks under it, and the delete operation says so. Nothing is left pointing at a project that no longer exists.paths:
/projects/{projectId}:
delete:
operationId: deleteProject
description: >
Deletes the project. Tasks under the project are kept and must be
deleted separately.
parameters:
- name: projectId
in: path
required: true
schema:
type: string
responses:
"204":
description: Project deleted.
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTaskWhy:The tasks sit under the project, so deleting the project has to delete them. This delete keeps them around instead, which leaves tasks referencing a project that is gone. The hierarchy promised a cascade; this breaks it.
- Scan
$.pathsfor nested collections: a path segment of the form/{parentId}/<childCollection>where the child sits under a parent resource path. - For each parent resource path with children nested under it, check whether
$.pathsdefines adeleteoperation on the parent (for exampledeleteon/projects/{projectId}). - Read that delete operation's
descriptionand its response descriptions. Confirm they state the cascade: deleting the parent removes the nested children. - Flag the operation when the docs say children are kept, say they must be deleted separately, or stay silent about what happens to nested resources.
- Scan
Access to a parent resource may imply access to its child resources.
A child resource must not belong to multiple parents.
paths:
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTask
/teams/{teamId}:
get:
operationId: getTeamWhy:A task belongs to one project. The team that works on the project refers to tasks by ID instead of owning them under a second nested path.
paths:
/projects/{projectId}/tasks/{taskId}:
get:
operationId: getTaskByProject
/teams/{teamId}/tasks/{taskId}:
get:
operationId: getTaskByTeamWhy:Now the same task is reachable under a project parent and a team parent. Two parents leave ownership, access, and cascade-delete behavior ambiguous.
List the nested collection paths in
$.paths. For each one, note the child collection identifier (the last collection segment) and its parent collection segment.Group the paths by child collection identifier. Flag any child collection that shows up under more than one distinct parent collection.
For each flagged child, confirm it's the same resource type, not a coincidental name reuse. Compare the resource schemas and the ID path parameters.
When the same child resource is reachable under two parents, it violates the rule. Recommend one owning parent, and reference the child from the other place by ID.