Commit 478121f6 authored by Daniyal Arshad's avatar Daniyal Arshad 2️⃣
Browse files

Record decisions and endpoints in Work Item REST API design doc

parent 503b927a
Loading
Loading
Loading
Loading
+99 −8
Changes for content/handbook/engineering/architecture/design-documents/work_item_rest_api/_index.md: 99 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -3,7 +3,7 @@ title: "Work Item REST API"
description: "Design document for the Work Item REST API, a resource-oriented interface that aligns with GitLab REST conventions."
status: ongoing
creation-date: "2026-02-05"
authors: ["@nicolasdular", "@mdangelo6", "@msaleiko"]
authors: ["@nicolasdular", "@mdangelo6", "@msaleiko", "@daniyalAD", "@brytannia"]
coaches: ["ntepluhina", "@engwan"]
dris: []
owning-stage: "~devops::plan"
@@ -54,13 +54,13 @@ For filtering, we can either:

Regardless of what we use, we want parity by design and never ship updates to either the REST or GraphQL exclusively.

For pagination, we require keyset pagination for these endpoints, asking clients to replace `page` identifiers with cursors derived from the sort order.
Pagination is keyset-based on every endpoint. The mechanics are in the [endpoint reference](endpoints.md#pagination).

#### Flexible response

We retain some of the GraphQL flexibility by adapting some of the JSON:API concept of [sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets).

1. By default, we only return the Work Item `id`, `global_id`, `iid`, `title`, and `title_html`.
1. Only `id`, `global_id`, `iid`, and `title` are always returned. Other fields need to be requested explicitly.
2. By default, no feature or widget is added as part of the response.
3. Other top-level fields must be requested specifically via a `fields` param.
4. For the features/widgets, we add a separate `features` param.
@@ -76,7 +76,7 @@ The reasons for supporting sparse fields are:
2. It reduces payload for clients, which can be especially important for agents that need to minimize context size.
3. It gives us insights into how fields are used within our API.

Note: We will not allow requesting all types of `features` in the listing endpoint. For example, we will allow the `hierarchy` feature only as part of a single Work Item request. When requesting `hierarchy` as part of the listing endpoint, we would return an error.
Every `features` value is available on the listing endpoint. `hierarchy` preloads parent visibility there so it does not cause N+1 queries. Where a feature is too expensive for a list response we split it into a separate list entity or a sub-resource endpoint rather than rejecting the request.

#### Example requests

@@ -98,7 +98,7 @@ Note: We will not allow requesting all types of `features` in the listing endpoi
    "https://gitlab.example.com/api/v4/projects/gitlab-org%2Fplan/-/work_items/42?features=labels,hierarchy"
  ```

  This call returns Work Item `42` with only `id`, `iid`, `global_id`, `title`, and `title_html` and includes the `labels` and `hierarchy` features in the response.
  This call returns Work Item `42` with only `id`, `iid`, `global_id`, and `title`, and includes the `labels` and `hierarchy` features in the response.

### Creating Work Items

@@ -136,7 +136,7 @@ curl --request PATCH \
  --header "Content-Type: application/json" \
  --data '{
    "title": "Work Item REST API rollout",
    "state": "closed",
    "state_event": "close",
    "features": {
      "description": { "description": "Track the rollout milestones and metrics." },
      "labels": { "add_label_ids": [81], "remove_label_ids": [23, 47] }
@@ -145,7 +145,12 @@ curl --request PATCH \
  "https://gitlab.example.com/api/v4/namespaces/gitlab-org%2Fplan/-/work_items/42"
```

This call updates the title and state of Work Item `42`, refreshes the description, and adjusts labels through the flattened `features` object.
This call:

- updates the title of Work Item `42`
- closes it through `state_event`
- refreshes the description
- adjusts labels through the flattened `features` object

#### Per-feature endpoints (future consideration)

@@ -173,6 +178,92 @@ curl --request DELETE \
  "https://gitlab.example.com/api/v4/projects/gitlab-org%2Fplan/-/work_items/42"
```

## Implemented endpoints

The endpoint reference, covering every route, parameter, payload, pagination style, and feature flag, lives on its own page: [Work Item REST API endpoints](endpoints.md). It stands in for the public `doc/api/` page until the API leaves its feature flag, at which point it moves there.

## Rollout plan

The API will be marked as `experimental` and controlled via the `:work_item_rest_api` flag (default off with the user as actor). Since it is crucial to get the REST API right and we cannot introduce breaking changes, we will remove the `experimental` tag only after we are certain that the API meets our expectations.
The API is marked as `experimental` and every route is `hidden`, so none of it appears in the public API reference yet. Since it is crucial to get the REST API right and we cannot introduce breaking changes once it is public, we will remove the `experimental` tag only after we are certain that the API meets our expectations.

Flag coverage is not uniform, and the difference matters when judging what we can still change:

1. The single Work Item, create, update, delete and sub-resource endpoints are controlled by the `work_item_rest_api` flag (default off, with the user as actor). They return `403` when it is disabled.
1. The listing endpoint has completed its rollout and is no longer behind a flag. It was gated by `work_item_rest_api_index` while it rolled out, and once that flag had been enabled globally for a week without issues we removed it. The endpoint also does not require authentication, so anonymous requests succeed for work items in public projects and groups, matching the GraphQL API. Its protection against breaking changes is now the `hidden` and `experimental` status alone.
1. `work_item_rest_api_frontend_users` does not gate any endpoint. It only controls whether the Work Items list and board frontend reads the list from REST instead of GraphQL.

## Decision registry

1. [Build a dedicated Work Items REST API instead of extending the Issues REST API](https://gitlab.com/gitlab-org/gitlab/-/issues/368055#note_1227097586), with the long-term intent of deprecating the Issues API.

   Other work item types (requirement, test case, objective, key result) share Task's widget set and would hit the same parity gaps; a dedicated API gives the flexibility Work Items need.

1. [Hand-write the REST API rather than auto-generate it from GraphQL](https://gitlab.com/gitlab-org/gitlab/-/issues/368055#note_1304000657).

   The API Vision Working Group's REST-wrapper PoCs targeted REST v5 and would take far too long to become feature-complete. Two blockers were never solved: API versioning, and the divergent deprecation policies of GraphQL versus REST.

1. [Postpone the REST API until the Work Items GraphQL API left Alpha and the data model stabilized](https://gitlab.com/gitlab-org/gitlab/-/issues/368055#note_1243016134).

   Defining REST earlier would have forced breaking changes, which are far harder to make on REST than on an Alpha GraphQL API. This is the origin of the experiment-behind-a-flag posture that shows up again in the rollout decisions below.

1. [Do not deprecate the Epics REST API as part of this work](https://gitlab.com/gitlab-org/gitlab/-/issues/368055#note_1917379056).

1. [Aim for parity with GraphQL by design, with GraphQL as the source of truth for filters and response schema](https://gitlab.com/groups/gitlab-org/-/work_items/9673#note_3052544341).

1. [Design the REST API for customer needs rather than frontend parity, so only the list query moves to REST](https://gitlab.com/groups/gitlab-org/-/work_items/22398#note_3517626531).

   The frontend migrates the list query alone, purely for performance. An unstated assumption that the whole frontend would migrate had been driving endpoints to mirror GraphQL mutations; once corrected, reshaping those endpoints cost the frontend nothing, and this unlocked the linked-items and hierarchy carve-outs described below.

1. [Do not omit widgets that already exist in GraphQL or the legacy issue and epic REST APIs, even where demand is unclear](https://gitlab.com/groups/gitlab-org/-/work_items/22398#note_3514528434).

   There is no way to know which attributes third parties depend on. Omitting them risks breaking existing workflows and blocks migration off the legacy endpoints.

1. [Use the generic `PATCH` with a flattened `features` object for most widgets, and add a dedicated sub-endpoint only when a widget is a collection of separately addressable entities](https://gitlab.com/groups/gitlab-org/-/work_items/22398#note_3517626531).

   If a client needs to reference an entity by its own id, or unlink or reorder it, it is a child collection and cannot be folded into a scalar PATCH. The `fields` and `features` design already solves the fat-response problem for reads, so it is specifically the write side of collections that needs splitting out. See also [the rubric for splitting collection writes](https://gitlab.com/groups/gitlab-org/-/work_items/22398#note_3521914109).

1. [Remove `features.linked_items` from create and update, keep it read-only on show, and manage links through `POST` and `DELETE /-/work_items/:iid/linked_items`](https://gitlab.com/groups/gitlab-org/-/work_items/22398#note_3516168878).

   The field was additive only. It could not unlink or reorder, and a link has its own id and per-link authorization.

1. [Remove `features.hierarchy.children_ids` from create and update while keeping `features.hierarchy.parent_id`, and manage children through dedicated endpoints](https://gitlab.com/groups/gitlab-org/-/work_items/22398#note_3516168878).

   `children_ids` had the same additive-only problem, and unlinking previously required patching each child's `parent_id` to null, which is backwards and unsafe under concurrent tree changes. `parent_id` is a genuine scalar property of the item and stays inline. The children write endpoints (`POST`/`DELETE`/`PUT .../children/:child_id`) have since shipped.

1. [Build Work Item native notes and discussions CRUD endpoints rather than leaving writes on the legacy `/issues/:iid/notes` API](https://gitlab.com/groups/gitlab-org/-/work_items/21728#note_3490998470).

   `features.notes` on PATCH only accepts `discussion_locked`. More importantly, the legacy endpoint routes through Issues services and silently drops work-item-only quick actions such as `/status`, so the behavior differs, not just the URL. See also [the discussion on quick actions dropped by the legacy notes API](https://gitlab.com/groups/gitlab-org/-/work_items/21728#note_3521919684).

1. [Allow a single feature name to resolve to two entity shapes, a basic one on the list endpoint and a detail one on show, selected by endpoint rather than by a client parameter](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/228670).

   This keeps listing fast where a feature is N+1 prone or carries data a list view never shows.

1. [Expose any feature that needs its own pagination as a separate sub-endpoint rather than as a `features` key](https://gitlab.com/groups/gitlab-org/-/work_items/21728).

   GraphQL supports nested connection pagination and REST does not. This is the principle behind the notes, discussions, children and linked items endpoints.

1. [Declare `work_item_type_ids` as a first-class `Array[Integer]` filter and remove the base type `types` filter before GA](https://gitlab.com/gitlab-org/gitlab/-/work_items/605888#note_3552897024).

   `work_item_type_ids` previously worked only as an undeclared parameter leaking through the raw params hash. Base types are an internal detail that is actively misleading with custom types, since `types=issue` also returns every custom type built on the issue base type. See also [the merge request removing the `types` filter](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/245936).

1. [Add `work_item_type_names` as a REST-only filter, case-insensitive, where unknown names match nothing rather than raising](https://gitlab.com/gitlab-org/gitlab/-/work_items/605888#note_3571780518).

   Integer type ids are unusable for custom types created per namespace, and universal tooling such as gitlab-triage writes type names in YAML and cannot know ids ahead of time. Matching nothing on an unknown name is consistent with how an unknown id already behaves.

1. [Do not bring `exclude_group_work_items` and `exclude_projects` to parity; record them as intentional exclusions in the parity spec](https://gitlab.com/gitlab-org/gitlab/-/issues/595010).

   They were a temporary GraphQL workaround from before the `traversal_ids` optimization, so replicating them would cement a workaround as API surface.

1. [Authorize `:read_work_item` against the work item itself on the single item endpoint, not only against its container, and return 404 rather than 403 when access is denied](https://gitlab.com/gitlab-org/gitlab/-/issues/603898).

   Authorizing only the parent leaks individual work items, such as confidential ones, to anyone who can read the container. 404 matches the list endpoint and avoids confirming existence.

1. [Run the beta on GitLab.com only, enabled per group by feature flag, without committing to the design, so breaking changes remain possible](https://gitlab.com/gitlab-org/gitlab/-/issues/599248#note_3461368353).

1. [Defer the `participants` widget out of Beta and GA and track it in a dedicated out-of-scope epic rather than dropping it silently](https://gitlab.com/groups/gitlab-org/-/work_items/22398#note_3514528434).

   Participants was historically a performance bottleneck. Closing the work as "won't do" in a tracked epic keeps the exclusion auditable and reversible, and open to community contribution. See also [the issue closing the participants widget](https://gitlab.com/gitlab-org/gitlab/-/issues/601069#note_3516576976).
1. [Use keyset pagination on every endpoint, exposed as an opaque cursor](https://gitlab.com/gitlab-com/content-sites/handbook/-/merge_requests/20950#note_3788496964).
   A client should not have to inspect response headers to learn which mechanism a given sort uses, and a uniform cursor contract leaves the implementation free to change. Where an ordering has no keyset form, the server pages by offset behind the same cursor. Implementation is tracked in [gitlab-org/gitlab#628176](https://gitlab.com/gitlab-org/gitlab/-/work_items/628176).
1. [Return `400` for unknown `fields` and `features` values, and omit features an item's type does not support or that are unlicensed](https://gitlab.com/gitlab-com/content-sites/handbook/-/merge_requests/20950#note_3788728139).
   A `200` should mean the request was understood in full, so an unrecognized name is rejected rather than dropped, as GraphQL does at query validation. A valid feature that an individual item cannot have is left out of that item instead, also mirroring GraphQL, because a list response spans several work item types and a single `400` would make mixed-type lists unusable. See also [the discussion on failing loudly](https://gitlab.com/gitlab-com/content-sites/handbook/-/merge_requests/20950#note_3822228190). Implementation is tracked in [gitlab-org/gitlab#629477](https://gitlab.com/gitlab-org/gitlab/-/work_items/629477).
+624 −0

File added.

Preview size limit exceeded, changes collapsed.