Skip to main content
Adopt

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.

  1. 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: getUserTask
    Why:

    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: deleteUser
    Why:

    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.

    1. 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/{}.

    2. Compare the normalized forms. If two distinct path keys normalize to the same string, they address the same resource and violate uniqueness. Flag them.

    3. 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.

  2. 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: getTeamMember
    Why:

    Every boundary uses a single slash, so the hierarchy is obvious: a members collection nested under a specific team.

    paths:
    /teams/{teamId}//members/{memberId}:
    get:
    operationId: getTeamMember
    Why:

    The double slash between {teamId} and members leaves an empty segment. That empty segment means nothing, and it breaks path matching in routers and generated clients.

    1. List every path key under $.paths.
    2. For each key, check that a single / separates each pair of segments.

    3. Reject any key that contains //. A double slash leaves an empty segment.

    4. Reject any key where two segment names run together with no slash between them.

  3. Double slashes (//) must not be used to separate segments of a resource identifier.

    paths:
    /projects/{projectId}/tasks/{taskId}:
    get:
    operationId: getTask
    Why:

    Exactly one slash sits between each segment, so there are no empty segments.

    paths:
    /projects/{projectId}//tasks/{taskId}:
    get:
    operationId: getTask
    Why:

    The // between {projectId} and tasks leaves an empty segment. Use a single slash.

  4. Resource identifier components should alternate between collection identifiers and resource IDs.

    paths:
    /projects/{projectId}:
    get:
    operationId: getProject
    /projects/{projectId}/tasks/{taskId}:
    get:
    operationId: getTask
    Why:

    Segments alternate collection then ID at every level: projects then {projectId}, then tasks then {taskId}. Each ID follows the collection it belongs to.

    paths:
    /projects/{projectId}/{taskId}:
    get:
    operationId: getTask
    Why:

    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.

  5. Collection identifiers must be in camelCase.

    paths:
    /users/{userId}:
    get:
    operationId: getUser
    /users/{userId}/projectMembers:
    get:
    operationId: listProjectMembers
    Why:

    Both users and projectMembers start lowercase and contain only letters, so they match the camelCase pattern.

    paths:
    /Users/{userId}:
    get:
    operationId: getUser
    /Users/{userId}/project_members:
    get:
    operationId: listProjectMembers
    Why:

    Users starts with an uppercase letter and project_members contains an underscore. Neither is camelCase.

  6. 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: listUserApiKeys
    Why:

    Both users and apiKeys start lowercase and use only ASCII letters and digits. apiKeys is camelCase, which the pattern allows.

    paths:
    /Users/{userId}/api_keys:
    get:
    operationId: listUserApiKeys
    Why:

    Users starts with an uppercase letter and api_keys has an underscore. Neither matches /[a-z][a-zA-Z0-9]*/.

  7. 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: listTasks
    Why:

    users, projects, and tasks are 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: listTasks
    Why:

    user, project, and task are singular. A collection holds many resources, so each segment should be plural: users, projects, tasks.

    paths:
    /info:
    get:
    operationId: getInfo
    Why:

    info has no separate plural form, so the singular is correct. This is the documented exception, not a violation.

    1. List the path segments under $.paths for each path. Skip segments wrapped in braces, like {userId}, since those are resource IDs, not collection identifiers.

    2. For each remaining segment, decide whether it names a collection of resources. Those are the segments to check for plurality.

    3. Check that each collection identifier is plural. If it is singular, flag it and suggest the plural form.

    4. 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.

  8. 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-e07fc1f90ae7
    Why:

    userId is 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.com
    Why:

    email is client-provided and mutable. When a user changes their address, the resource URL changes with it, and every existing reference breaks.

    1. 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}.

    2. 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.

    3. 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.

    4. 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.

  9. 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: getTask
    Why:

    Each parameter is the singular collection name plus Id. projects becomes projectId, tasks becomes taskId.

    paths:
    /projects/{id}:
    get:
    operationId: getProject
    /projects/{projectId}/tasks/{task}:
    get:
    operationId: getTask
    Why:

    {id} drops the resource name, so the parameter no longer says what it identifies. {task} leaves off the Id suffix. Both should read projectId and taskId.

    1. Walk each key under $.paths and find the path templates: the segments wrapped in braces, like {taskId}.

    2. For each path parameter, read the collection identifier that precedes it, the segment just before the templated one.

    3. Make that collection identifier singular and add Id. tasks should give taskId. When a collection has no distinct plural ("info"), use the singular as-is plus Id.

    4. Flag any parameter that drops the resource name ({id}), leaves off the Id suffix ({task}), or uses a name unrelated to its parent collection.

  10. 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: string
    Why:

    projectId and taskId both open with a lowercase letter and hold only letters. Valid camelCase resource 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: string
    Why:

    project_id is snake_case and TaskID is PascalCase. A resource ID has to be camelCase, so neither one passes.

  11. 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: string
    Why:

    The path parameter projectId and the representation field projectId share 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: string
    Why:

    The path uses projectId, but the body exposes the same identifier as id. The two names disagree, so a consumer cannot rely on one key for the resource's ID.

    1. For each path in $.paths, list the path parameters (the {...} segments) that identify the resource the path addresses.
    2. Find the schema for that resource. It is usually the response body schema of the GET on the item path. Resolve any $ref.
    3. Confirm the resolved schema declares a property whose name matches the path parameter exactly, casing included.
    4. Flag any case where the representation names the identifier differently (for example path {projectId} but field id) or omits it entirely.
  12. Resource identifiers should not use abbreviations, unless the abbreviation is well understood (for example, IP, AWS, TCP).

    paths:
    /organizations/{organizationId}/configurations:
    get:
    operationId: listConfigurations
    Why:

    Full words leave no room for doubt. Nobody has to wonder what organizations refers to.

    paths:
    /orgs/{orgId}/cfgs:
    get:
    operationId: listCfgs
    Why:

    orgs and cfgs are invented shorthands, not abbreviations anyone already knows. The reader has to expand them in their head.

    paths:
    /servers/{serverId}/ipRanges:
    get:
    operationId: listIpRanges
    Why:

    Everyone knows what ip means, so the short form beats internetProtocolRanges for clarity.

    1. List every path key under $.paths and split each one into segments on the slash.
    2. Within each path, separate the collection identifiers from the path parameters (the {...} parts). Check both.
    3. 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.
    4. 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.
    5. Flag any shortened form that does not pass and recommend the spelled-out word.
  13. 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/json
    Why:

    The path identifies the report. The caller asks for a representation through the Accept header, so one identifier returns JSON, CSV, or gzip without change.

    paths:
    /reports/{reportId}.csv:
    get:
    operationId: getReportCsv
    /reports/{reportId}.json:
    get:
    operationId: getReportJson
    Why:

    The .csv and .json extensions bake the serialization format into the identifier. Now one resource has two identifiers, and every new format wants another path.

    1. Collect every path key under $.paths.
    2. Split each path on / and check every segment, including the ones that end in a path parameter like {reportId}.

    3. Flag any segment whose literal text ends in a dot followed by a format token such as .json, .csv, .xml, .gz, or .zip.

    4. 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 Accept header media type instead.

  14. 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.

note

Relationships between resources expressed as nested collections or hierarchical relationships have certain implications that API producers need to consider.

  1. 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: getProjectMember
    Why:

    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: getProjectMember
    Why:

    projectMembers repeats the parent projects that already sits right in front of it in the path. The project prefix is noise.

    1. In $.paths, find paths with multiple collection segments, where a child collection nests under a parent collection (for example /parents/ {parentId}/children).

    2. 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 projectMembers under projects).

    3. 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).

    4. 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.

  2. 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: getTask
    Why:

    tasks is nested under projects. 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: getTask
    Why:

    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.

    1. Scan $.paths for nested collections: a path segment of the form /{parentId}/<childCollection> where the child sits under a parent resource path.
    2. For each parent resource path with children nested under it, check whether $.paths defines a delete operation on the parent (for example delete on /projects/{projectId}).
    3. Read that delete operation's description and its response descriptions. Confirm they state the cascade: deleting the parent removes the nested children.
    4. 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.
  3. Access to a parent resource may imply access to its child resources.

  4. A child resource must not belong to multiple parents.

    paths:
    /projects/{projectId}/tasks/{taskId}:
    get:
    operationId: getTask
    /teams/{teamId}:
    get:
    operationId: getTeam
    Why:

    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: getTaskByTeam
    Why:

    Now the same task is reachable under a project parent and a team parent. Two parents leave ownership, access, and cascade-delete behavior ambiguous.

    1. List the nested collection paths in $.paths. For each one, note the child collection identifier (the last collection segment) and its parent collection segment.

    2. Group the paths by child collection identifier. Flag any child collection that shows up under more than one distinct parent collection.

    3. 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.

    4. 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.