From 9c71733735d5e46493d523e04063ca841460a029 Mon Sep 17 00:00:00 2001 From: "Kris (K) Kula" Date: Tue, 18 Aug 2026 17:30:48 +0200 Subject: [PATCH 1/7] ADR: REST API Guidelines --- contributing/ADRs/ADRs.md | 1 + .../ADRs/back-end/rest-api-guidelines.md | 140 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 contributing/ADRs/back-end/rest-api-guidelines.md diff --git a/contributing/ADRs/ADRs.md b/contributing/ADRs/ADRs.md index f709bf70b6..5ae0ac252c 100644 --- a/contributing/ADRs/ADRs.md +++ b/contributing/ADRs/ADRs.md @@ -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 diff --git a/contributing/ADRs/back-end/rest-api-guidelines.md b/contributing/ADRs/back-end/rest-api-guidelines.md new file mode 100644 index 0000000000..25d9440319 --- /dev/null +++ b/contributing/ADRs/back-end/rest-api-guidelines.md @@ -0,0 +1,140 @@ +--- +title: "ADR: REST API Guidelines" +--- + +## Background + +This ADR captures the conventions we want new endpoints to follow. It applies to new work. Reshaping an existing endpoint is a breaking change for its consumers, so bringing an older endpoint into line usually means shipping a new endpoint alongside it and deprecating the old one. + +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 audience of the new endpoint + +* `/api/client` — server SDKs evaluating flags. Public, stable. +* `/api/frontend` — browser SDKs evaluating flags. Public, stable. +* `/edge` — Unleash Edge. +* `/api/integration/*` — new integrations. +* `/api/ui` — new UI-only endpoints. +* `/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). Prefix says who the endpoint is for; `release` says how stable it is. + +Prefer purpose-built endpoints for specific UI needs over stretching a generic endpoint with a narrow filter. A dedicated endpoint documents intent, keeps its response shape minimal, and can be optimized independently. See [Write model vs Read models](/contributing/ADRs/back-end/write-model-vs-read-models) for the internal read/write split this endpoint pattern reflects. + +### Shadowing dynamic path segments + +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 +{ + "items": [ ... ] +} +``` +* This makes it easier to extend the response without breaking the API. E.g.: to add pagination metadata (`total`, `hasMore`, cursors). + +```json +{ + "total": 2000, + "items": [ ... ] +} +``` +* Name the collection field after the resource (`users`, `flagCreators`, `events`) rather than a generic `data`. It reads better at call sites and matches existing endpoints. +* New list endpoints should have a `limit` by default. + * While adding `limit` it usually makes sense to add [Pagination](#pagination). + * But, if you're not adding pagination (even though you should) then think of some other way to get the data beyond the limit — see [Query parameter conventions](#query-parameter-conventions) for the standard sort and filter names. +* Endpoints should set a `maxLimit`. Always return the applied `total`, `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. + +```json +{ + "total": 2000, + "limit": 1000, + "offset": 0, + "items": [ ... ] +} +``` + +### 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` in the response so the UI can render counts and page controls. +* 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. +* Do not skip pagination because "the list will be short". Instances vary; assumptions about size that hold for one customer routinely fail for another. Response size is not always the limiting factor; sometimes DB load may force us to introduce a limit. + +### Query parameter conventions + +Reuse existing names before inventing new ones: + +* `?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 UI does not render 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 + +* 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. + +## 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. +* 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 + +* Purpose-built endpoints multiply endpoint count compared to a single generic endpoint with many filters. We accept this in exchange for smaller responses and clearer intent. +* Enveloping list responses is a breaking change for endpoints that currently return bare arrays. This convention applies to new endpoints; migrating an existing one means adding a new endpoint and deprecating the old. This plan assumes we leave the existing endpoints as is making them inconsistent with our new guidelines. +* 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. \ No newline at end of file From 38a91d2f56112b88095de16474cbc4e41be56686 Mon Sep 17 00:00:00 2001 From: "Krzysztof (Kris) Kula" Date: Mon, 31 Aug 2026 12:38:03 +0200 Subject: [PATCH 2/7] Update contributing/ADRs/back-end/rest-api-guidelines.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gastón Fournier --- contributing/ADRs/back-end/rest-api-guidelines.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributing/ADRs/back-end/rest-api-guidelines.md b/contributing/ADRs/back-end/rest-api-guidelines.md index 25d9440319..ff6e64934f 100644 --- a/contributing/ADRs/back-end/rest-api-guidelines.md +++ b/contributing/ADRs/back-end/rest-api-guidelines.md @@ -113,7 +113,7 @@ Reuse existing names before inventing new ones: 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 UI does not render a field, do not return it. +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. From 2e5e4da1b370b2d2f2493fc0951a042e8f6c2e60 Mon Sep 17 00:00:00 2001 From: "Kris (K) Kula" Date: Mon, 31 Aug 2026 13:30:35 +0200 Subject: [PATCH 3/7] Add more disclaimers --- .../ADRs/back-end/rest-api-guidelines.md | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/contributing/ADRs/back-end/rest-api-guidelines.md b/contributing/ADRs/back-end/rest-api-guidelines.md index ff6e64934f..f5b5c12832 100644 --- a/contributing/ADRs/back-end/rest-api-guidelines.md +++ b/contributing/ADRs/back-end/rest-api-guidelines.md @@ -4,7 +4,9 @@ title: "ADR: REST API Guidelines" ## Background -This ADR captures the conventions we want new endpoints to follow. It applies to new work. Reshaping an existing endpoint is a breaking change for its consumers, so bringing an older endpoint into line usually means shipping a new endpoint alongside it and deprecating the old one. +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). @@ -14,13 +16,12 @@ General guidelines for new API endpoints. It's fine to do something different if ### URL structure -Pick the prefix that matches the audience of the new endpoint +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. * `/edge` — Unleash Edge. * `/api/integration/*` — new integrations. -* `/api/ui` — new UI-only endpoints. * `/api/admin` — documented public API. Other prefixes exist for context, but new endpoints should not add to them: @@ -30,9 +31,7 @@ Other prefixes exist for context, but new endpoints should not add to them: * `/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). Prefix says who the endpoint is for; `release` says how stable it is. - -Prefer purpose-built endpoints for specific UI needs over stretching a generic endpoint with a narrow filter. A dedicated endpoint documents intent, keeps its response shape minimal, and can be optimized independently. See [Write model vs Read models](/contributing/ADRs/back-end/write-model-vs-read-models) for the internal read/write split this endpoint pattern reflects. +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. ### Shadowing dynamic path segments @@ -78,10 +77,10 @@ What to do: } ``` * Name the collection field after the resource (`users`, `flagCreators`, `events`) rather than a generic `data`. It reads better at call sites and matches existing endpoints. -* New list endpoints should have a `limit` by default. - * While adding `limit` it usually makes sense to add [Pagination](#pagination). - * But, if you're not adding pagination (even though you should) then think of some other way to get the data beyond the limit — see [Query parameter conventions](#query-parameter-conventions) for the standard sort and filter names. -* Endpoints should set a `maxLimit`. Always return the applied `total`, `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. +* 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 { @@ -96,9 +95,9 @@ What to do: * 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` in the response so the UI can render counts and page controls. +* 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. -* Do not skip pagination because "the list will be short". Instances vary; assumptions about size that hold for one customer routinely fail for another. Response size is not always the limiting factor; sometimes DB load may force us to introduce a limit. +* 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 @@ -135,6 +134,5 @@ This is the response-side counterpart to [Separation of request and response sch ### Trade-offs -* Purpose-built endpoints multiply endpoint count compared to a single generic endpoint with many filters. We accept this in exchange for smaller responses and clearer intent. -* Enveloping list responses is a breaking change for endpoints that currently return bare arrays. This convention applies to new endpoints; migrating an existing one means adding a new endpoint and deprecating the old. This plan assumes we leave the existing endpoints as is making them inconsistent with our new guidelines. +* 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. \ No newline at end of file From a3119014fec7bb5d143de0b262a5f9456f5b2d64 Mon Sep 17 00:00:00 2001 From: "Kris (K) Kula" Date: Mon, 7 Sep 2026 13:46:47 +0200 Subject: [PATCH 4/7] Add positive of the trade-off --- contributing/ADRs/back-end/rest-api-guidelines.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contributing/ADRs/back-end/rest-api-guidelines.md b/contributing/ADRs/back-end/rest-api-guidelines.md index f5b5c12832..24c9b2fdec 100644 --- a/contributing/ADRs/back-end/rest-api-guidelines.md +++ b/contributing/ADRs/back-end/rest-api-guidelines.md @@ -135,4 +135,4 @@ This is the response-side counterpart to [Separation of request and response sch ### 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. \ No newline at end of file +* 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. \ No newline at end of file From 62f8b15b0633765a33cff91e36ee4d84599b828d Mon Sep 17 00:00:00 2001 From: "Kris (K) Kula" Date: Mon, 7 Sep 2026 13:53:12 +0200 Subject: [PATCH 5/7] Add a warning about SDK endpoints --- contributing/ADRs/back-end/rest-api-guidelines.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributing/ADRs/back-end/rest-api-guidelines.md b/contributing/ADRs/back-end/rest-api-guidelines.md index 24c9b2fdec..0f0bc34ae0 100644 --- a/contributing/ADRs/back-end/rest-api-guidelines.md +++ b/contributing/ADRs/back-end/rest-api-guidelines.md @@ -33,6 +33,10 @@ Other prefixes exist for context, but new endpoints should not add to them: 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 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. From e49dd16273b7b5dfe7e6e8fae8b539383013709d Mon Sep 17 00:00:00 2001 From: "Kris (K) Kula" Date: Mon, 7 Sep 2026 13:55:17 +0200 Subject: [PATCH 6/7] more concrete examples --- contributing/ADRs/back-end/rest-api-guidelines.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contributing/ADRs/back-end/rest-api-guidelines.md b/contributing/ADRs/back-end/rest-api-guidelines.md index 0f0bc34ae0..94c5b7c493 100644 --- a/contributing/ADRs/back-end/rest-api-guidelines.md +++ b/contributing/ADRs/back-end/rest-api-guidelines.md @@ -69,7 +69,7 @@ What to do: ```json { - "items": [ ... ] + "users": [ ... ] } ``` * This makes it easier to extend the response without breaking the API. E.g.: to add pagination metadata (`total`, `hasMore`, cursors). @@ -77,10 +77,10 @@ What to do: ```json { "total": 2000, - "items": [ ... ] + "users": [ ... ] } ``` -* Name the collection field after the resource (`users`, `flagCreators`, `events`) rather than a generic `data`. It reads better at call sites and matches existing endpoints. +* 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. @@ -91,7 +91,7 @@ What to do: "total": 2000, "limit": 1000, "offset": 0, - "items": [ ... ] + "users": [ ... ] } ``` From 3c792c36a800a0607b0558e0821603390c816e2c Mon Sep 17 00:00:00 2001 From: "Kris (K) Kula" Date: Mon, 7 Sep 2026 13:59:16 +0200 Subject: [PATCH 7/7] Add a small warning --- contributing/ADRs/back-end/rest-api-guidelines.md | 1 + 1 file changed, 1 insertion(+) diff --git a/contributing/ADRs/back-end/rest-api-guidelines.md b/contributing/ADRs/back-end/rest-api-guidelines.md index 94c5b7c493..387bb1f5c6 100644 --- a/contributing/ADRs/back-end/rest-api-guidelines.md +++ b/contributing/ADRs/back-end/rest-api-guidelines.md @@ -126,6 +126,7 @@ This is the response-side counterpart to [Separation of request and response sch * 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