Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributing/ADRs/ADRs.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ We are in the process of defining ADRs for the back end. At the time of writing
* [Frontend API Design](/contributing/ADRs/back-end/frontend-api-design)
* [Correct type dependencies](/contributing/ADRs/back-end/correct-type-dependencies)
* [API Version Tracking and Stability Lifecycle](/contributing/ADRs/back-end/api-version-tracking)
* [REST API Guidelines](/contributing/ADRs/back-end/rest-api-guidelines)

## Front-end ADRs

Expand Down
143 changes: 143 additions & 0 deletions contributing/ADRs/back-end/rest-api-guidelines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
---
title: "ADR: REST API Guidelines"
---

## Background

This ADR captures the conventions we want new endpoints to follow. It applies to new work — but that does not mean creating a replacement endpoint whenever an existing one falls short of these guidelines. Every public endpoint is a contract we maintain and deprecate for a long time; we have OpenAPI diff tooling precisely because breaking or removing one is expensive.

When an existing endpoint doesn't fit a new use case, first see whether it can support it in a backward-compatible way. Create a new endpoint only when a breaking change leaves no other path (e.g. turning a bare-array response into an envelope) — see [URL structure](#url-structure) for when a purpose-built endpoint is warranted.

For request-body conventions (handling `undefined` vs `null` on POST/PUT), see [POST/PUT API payload](/contributing/ADRs/back-end/POST-PUT-api-payload). For response-schema precision, see [Separation of request and response schemas](/contributing/ADRs/overarching/separation-request-response-schemas).

## Decision

General guidelines for new API endpoints. It's fine to do something different if your requirements are not typical, but it's what most new endpoints should default to.

### URL structure

Pick the prefix that matches the endpoint's role:

* `/api/client` — server SDKs evaluating flags. Public, stable.
* `/api/frontend` — browser SDKs evaluating flags. Public, stable.
Comment thread
krzychukula marked this conversation as resolved.
* `/edge` — Unleash Edge.
* `/api/integration/*` — new integrations.
* `/api/admin` — documented public API.

Other prefixes exist for context, but new endpoints should not add to them:

* `/api/signal-endpoint` — external webhooks calling into Unleash (integration).
* `/scim` — SCIM 2.0 user provisioning (integration).
* `/health`, `/ready`, `/internal-backstage` — operational endpoints for orchestrators and monitoring.
* `/auth/*`, `/invite`, `/logout`, `/feedback` — public browser flows.

Stability within any prefix is signalled by the `release: { alpha | beta | stable }` field — alpha endpoints are hidden from public docs. See [API Version Tracking and Stability Lifecycle](/contributing/ADRs/back-end/api-version-tracking). The URL prefix should describe the resource, not the current audience — an endpoint can graduate from alpha to stable without moving path.

#### SDK-facing prefixes

`/api/client` and `/api/frontend` are our strictest stability tier — even our oldest SDKs in the field must still understand these responses. When adding endpoints here, follow the rest of this ADR especially carefully; a subtle break can silently degrade flag evaluation in customer environments long before we hear about it.

### Shadowing dynamic path segments

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole section is new. Original version had just "Stop using shadowing." part, but didn't give us any ideas what to do instead. Maybe it won't be too hard to discuss here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with avoiding shadowing, but documentation alone doesn't protects us very well. When adding /projects/foo, it's quite hard for the developer to know that somewhere else /projects/:projectId exists, especially once routes live in different controllers.

Could we make this mechanically detectable instead?

For example, we could add a route lint/test that compares registered routes and flags if you're shadowing an existing route.

@krzychukula krzychukula Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm ok with you creating a project for it. If I try solving it now, I won't close this PR this year :D


If a route like `/api/admin/projects/:projectId` exists, a sibling `/api/admin/projects/some-word` forces `some-word` to become a reserved project id — we depend on the router matching the static route first. That reservation lives only in route registration order: reorder the controllers and the collision reappears, and each new sibling silently reserves some `:id` values that existing data may already contain.

What to do:

* Default: use a top-level sibling (`/api/admin/users-access-log` rather than `/api/admin/users/access-log`). It keeps the parent namespace free and avoids the reserved-id problem entirely.
* Long term, we may adopt `-` as a reserved segment for collection-level operations under a dynamic parent (e.g. `/api/admin/users/-/access-log`), following [Google AIP-159](https://google.aip.dev/159). We are not there yet — do not introduce it ad-hoc. If you have a case that would benefit, raise it so we can adopt the convention deliberately.
* Stop using shadowing. Existing cases stay as-is — we are not migrating. Treat it as legacy: don't extend it just because a similar endpoint already lives under the same collection. Every new sibling adds another implicit reserved id. E.g.:
* `/api/admin/user-admin/:id`
* `/api/admin/user-admin/search`
* `/api/admin/user-admin/validate-password`
* `/api/admin/segments/validate`

### Naming conventions

* Use `kebab-case` for the static parts e.g.:
* `/api/admin/release-plan-templates`
* `/api/admin/projects/default/environments/${environment}/change-requests`
* Use `camelCase` for query string parameters e.g.:
* `strategyId`
* `variantForFlag`
* Use `camelCase` for response body fields e.g.:
* `hasMore`
* `flagCreators`

### List response shape

* Return an object envelope, not a bare array:

```json
{
"users": [ ... ]
}
```
* This makes it easier to extend the response without breaking the API. E.g.: to add pagination metadata (`total`, `hasMore`, cursors).

```json
{
"total": 2000,
"users": [ ... ]
}
```
* Name the collection field after the resource (`users`, `flagCreators`, `events`) rather than a generic `data` or `items`. It reads better at call sites and matches existing endpoints.
* New list endpoints should have a `limit` by default.
* Endpoints should set a `maxLimit`.
* Always return the applied `limit` and `offset` in the response — even when the caller did not paginate — so the envelope stays consistent and callers can see what was actually used. E.g.: a request for `limit=10000000` may still return max `1000` items.
* Include `total` when the endpoint can support it (see [Pagination](#pagination) for when that makes sense).

```json
{
"total": 2000,
"limit": 1000,
"offset": 0,
"users": [ ... ]
}
```

### Pagination

* Because new list endpoints should have a `limit` by default, users need some way of getting values past the limit e.g.: `Load more` or see `page 2` of the data.
* New list endpoints should paginate by default. It is much cheaper to opt in from day one than to retrofit an endpoint whose clients rely on receiving the full list in one call.
* Default to offset/limit with `?offset=` and `?limit=`, and include `total` if possible. Use `hasMore` (or fetch `limit + 1`) when computing `total` would be too expensive.
* Choose cursor-based pagination (`?cursor=` + `hasMore`) only when stability across pages matters more than a known `total` — for example, endpoints served from a rapidly changing feed.
* Paginate any collection whose cardinality is unbounded, customer-controlled, or whose cost can materially grow. "Usually short" is not a reason to skip pagination. Instance sizes vary and DB load can force a limit later. Collections with a small, domain-defined upper bound (e.g. feature strategy types) can return the full list even withing the fist page of pagination for most cases.

### Query parameter conventions

Reuse existing names before inventing new ones:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


* `?q=` — free-text search across the natural user-visible fields (typically name/username/email for user-shaped endpoints). Do not require a minimum length; an empty `q` should behave the same as omitting it.
* `?offset=` / `?limit=` — pagination controls.
* `?sortBy=` / `?sortOrder=asc|desc` — sorting. Each endpoint documents its allowed `sortBy` values and its default; `sortOrder` defaults to `asc`.
* `?field=IS:value` — field-specific filters via the shared generic-query-params helper. Prefer this over one-off boolean flags or bespoke parameter names.

### Return only what the caller needs

Design the response shape for the specific use case. Do not return the full internal model on the theory that clients can "just pick what they want". It's way harder to remove problematic fields than add them when needed.

Every field adds wire cost and couples the client to the internal shape. If the intended consumer does not need a field, do not return it.

If callers legitimately need different amounts of data from the same list, prefer separate endpoints over a `?view=minimal|full` parameter — dedicated endpoints stay simpler to reason about and cache.

This is the response-side counterpart to [Separation of request and response schemas](/contributing/ADRs/overarching/separation-request-response-schemas): responses are tight and precise; request schemas can be more forgiving.

### Filter in SQL, not JS

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 - This has been a pet peeve for me for some of our existing endpoints, filtering in SQL also saves data transfer and increases response speed. Because we no longer transfer data out and then just use CPU time to toss it on the floor.

Does mean that the SQL will require thorough reviewing though.


* Do all row-level filtering in the SQL query, including any fallback logic ("skip rows with no name, username, or email"). Do not filter after the query has returned.
* Post-query filtering breaks pagination in two ways: `limit=100` can return fewer than 100 rows, and `total` no longer matches what the caller sees. This is not a corner case — it is the normal behavior any time the filter removes at least one row on the current page.
* Because filtering, pagination, and sorting now all execute in the query, treat new or modified SQL as a review hotspot: check the query plan on realistic data, confirm indexes exist for the filter and sort columns, and watch for accidental full scans. A bad plan degrades the endpoint directly instead of being masked by in-memory work.

## Consequences

### Positive

* New list endpoints paginate by default and behave the same way from the caller's perspective.
* Frontend and API consumers can predict the query params for search, pagination, and sorting without reading each endpoint's docs.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having a common PaginationParameters that can be used for collection endpoints is great

* Response shapes stay small and intentional; changing an internal model does not automatically change the API surface.
* Filtering behavior is consistent with pagination metadata, so the UI can trust `total` and page sizes.

### Trade-offs

* Enveloping list responses can be a breaking change for some endpoints. This convention applies to new endpoints; existing bare-array endpoints stay as they are unless there is an independent reason to reshape.
* Pushing all filtering into SQL sometimes means more complex queries (e.g. `COALESCE` for fallback columns). We accept the query complexity in exchange for correct pagination. But, Postgres' query planner is very good at planning queries it sees often, so this often leads to better response times and less data transferred between Unleash and Postgres.
Loading