API Guideline
Request/response conventions for search, filter, sort, pagination, and group-by APIs across MCM services.
API Guideline
A consistent convention to apply across all endpoints/services so clients rely on one mental model regardless of which API they call. This document is organized by API type — each numbered top-level section is a self-contained guideline for one kind of operation.
1. Search / Filter / Group By API
Covers list/search operations — any endpoint that returns multiple resources, optionally filtered, searched, sorted, paginated, or aggregated.
1.1 Overview
This is the mandatory contract for every search/filter/sort/pagination/groupBy endpoint across MCM — mcm-api, mcm-common-lib, every mcm-module-* service, mcm-ai, and any frontend that consumes them. Every new endpoint of this kind must comply starting now; existing non-compliant endpoints are migration debt to bring into compliance over time, not a permanently allowed second convention.
Rules
- One
search(orquery) endpoint per resource, POST-based, e.g.POST /orders/search— body holdsfilter,search,sort,pagination,groupByas top-level keys. Never query-string filtering, neverGETwith a body. - Filter is a flat list of
{ field, value }pairs, ANDed together. A scalar value is exact equality, an array is "one of these" ("IN"). An optionalop(eq/in/gte/lte/between) covers range comparisons. Don't introduce a new bespoke filter object per resource (e.g. a hand-rolledFooFilterRequestwith one named field per column) — use the flat array instead. groupBycombines grouping and aggregation into one{ fields, metrics, count }object on the same search endpoint —metricsaggregates named fields,countis a separate boolean for the row count per group. OmitgroupByentirely to get raw, ungrouped rows. Never split raw and grouped results across two endpoints with two different response shapes.- Pagination is offset-based only —
page/pageSizeeverywhere. No cursor-based pagination, no per-endpoint choice between styles. - Every successful response uses the
{ data, meta }envelope; every error response uses the{ error: {code, message, status, details, requestId, timestamp} }envelope, and the HTTP status code always matcheserror.status— never200with an embedded error object. - Error
codevalues come from the standard error codes table (or a documented, service-specific extension of it) and stay stable across API versions — clients branch oncode, never onmessage. - Expose a
POST /resource/filtersendpoint for any resource whose filters back a dropdown/autocomplete UI, unless there's a documented reason not to. - Document each endpoint's defaults explicitly — default
pageSize, defaultsort, defaultsearch.fields— since an omitted body field falls back to something, and that something should be written down, not discovered. - Version the contract, not just the code — if a field name or semantic must change, do it via a new API version, not a silent change.
1.2 Filtering
Filters are a flat array of { field, value } pairs combined with AND. Equality and "IN" are implicit from the shape of value: a scalar is exact equality, an array is "one of these." An optional op covers range comparisons on numeric/date fields. No nested and/or — keep it flat.
Request format
{
"filter": [
{ "field": "status", "value": "active" },
{ "field": "region", "value": "EU" }
]
}All conditions are ANDed together. If you need "value is one of many," pass an array as the value:
{
"filter": [
{ "field": "role", "value": ["admin", "editor"] }
]
}(Array value = "IN" match; scalar value = exact equality. This is the default behavior when op is omitted.)
1.2.1 Range operators
For numeric and date/time fields, add an optional op. Omit it for equality/IN; when present it must be one of:
op | value shape | Meaning |
|---|---|---|
eq (default) | scalar | Equality — same as omitting op |
in (default for array) | array | Value is one of the array |
gte | scalar | Greater-than-or-equal |
lte | scalar | Less-than-or-equal |
between | 2-element array [min, max] | Inclusive range |
{
"filter": [
{ "field": "cost", "op": "gte", "value": 1000 },
{ "field": "createdAt", "op": "between", "value": ["2026-07-01", "2026-07-31"] }
]
}Keep this operator list short and closed — it is not a general query language. If a need doesn't fit eq/in/gte/lte/between, that's a signal for a dedicated endpoint, not a new operator.
1.3 Search
A separate top-level field for free-text/fuzzy search, distinct from structured filter.
Request format
{
"search": {
"query": "john doe",
"fields": ["name", "email"]
}
}| Field | Type | Description |
|---|---|---|
search.query | string | Free-text query |
search.fields | string[] | Optional list to scope the search. Defaults to a documented set of fields if omitted. |
1.4 Sorting
Request format
{
"sort": [
{ "field": "status", "direction": "desc" },
{ "field": "name", "direction": "asc" }
]
}Array order defines tie-break priority (left to right / top to bottom).
1.5 Pagination
Offset-based only — page/pageSize everywhere. No cursor-based pagination; every endpoint uses the same request and response shape below, no per-API choice to make.
Request
{
"pagination": { "page": 2, "pageSize": 25 }
}Response envelope (pagination metadata)
{
"data": [ /* array of resources */ ],
"meta": {
"pagination": {
"page": 2,
"pageSize": 25,
"totalItems": 483,
"totalPages": 20,
"hasNextPage": true,
"hasPrevPage": true
}
}
}1.6 Group By — Raw vs Grouped Data
groupBy is optional, with three parts: fields (the group-by keys/dimensions), metrics (named fields to aggregate per group), and count (a boolean — include the row count per group). At least one of metrics (non-empty) or count: true must be present.
Each metrics entry is { field, fn }, where fn is the aggregation function and defaults to sum when omitted:
fn | Meaning |
|---|---|
sum (default) | Sum of the field across the group |
avg | Average of the field across the group |
count isn't a metric — it doesn't aggregate a named field, it counts rows in the group — so it's its own top-level groupBy.count: true flag rather than an entry in metrics.
Keep this list short and closed, same as the range operators above. Add min/max or other functions only if a real need arises.
A resource has exactly one search endpoint. Raw and grouped results both come from POST /resource/search — the presence or absence of groupBy in the request is what switches the response shape. Do not stand up a separate POST /resource/groupBy route with its own response shape; if one already exists, retire it in favor of groupBy on the search endpoint.
Grouped rows are shaped { fields, metrics }, plus a count field when groupBy.count was requested. fields is the resolved dimension values for that row, metrics the aggregated field values.
1.6.1 Raw data (no groupBy, or groupBy omitted entirely)
If groupBy is not present in the request, the API returns raw, ungrouped records — plain resource rows, not summarized.
Request
{
"filter": [ { "field": "status", "value": "shipped" } ],
"sort": [ { "field": "createdAt", "direction": "desc" } ],
"pagination": { "page": 1, "pageSize": 20 }
}(no groupBy key at all)
Response
{
"data": [
{ "id": "ord_1", "status": "shipped", "region": "EU", "total": 420.00, "createdAt": "2026-07-20T10:00:00Z" },
{ "id": "ord_2", "status": "shipped", "region": "US", "total": 980.00, "createdAt": "2026-07-19T14:22:00Z" }
],
"meta": {
"pagination": { "page": 1, "pageSize": 20, "totalItems": 42, "totalPages": 3, "hasNextPage": true, "hasPrevPage": false },
"grouped": false
}
}1.6.2 Grouped data (groupBy present)
Request
{
"filter": [ { "field": "status", "value": "shipped" } ],
"groupBy": {
"fields": ["region"],
"metrics": [ { "field": "total" } ]
},
"pagination": { "page": 1, "pageSize": 20 }
}(fn omitted = sum)
| Field | Description |
|---|---|
groupBy.fields | Array of fields to group by (the dimensions) |
groupBy.metrics | Array of { field, fn } — named fields to aggregate per group; fn defaults to sum |
groupBy.count | Boolean, default false — include the row count per group as a top-level count on each row |
Response
{
"data": [
{ "fields": { "region": "EU" }, "metrics": { "total": 4820.00 } },
{ "fields": { "region": "US" }, "metrics": { "total": 11250.00 } }
],
"meta": {
"pagination": { "page": 1, "pageSize": 20, "totalItems": 2, "totalPages": 1, "hasNextPage": false, "hasPrevPage": false },
"grouped": true,
"groupBy": { "fields": ["region"], "metrics": [ { "field": "total" } ] }
}
}pagination.totalItems and a group's count answer two different questions and are never the same number. When groupBy is present, data holds one row per group, so pagination.totalItems is the total number of groups (here, 2 — one for EU, one for US), for paging through the group rows themselves. count, if requested via groupBy.count: true, is a per-group value — the number of raw underlying records rolled up into that one group. Don't use totalItems as a stand-in for "how many raw records matched the filter"; request groupBy.count: true for that.
Worked example. Say a vulnerabilities resource has 500 raw rows matching the filter, split 200/150/150 across three severities. Grouping by severity with count:
{ "groupBy": { "fields": ["severity"], "count": true } }{
"data": [
{ "fields": { "severity": "high" }, "count": 200 },
{ "fields": { "severity": "medium" }, "count": 150 },
{ "fields": { "severity": "low" }, "count": 150 }
],
"meta": { "pagination": { "totalItems": 3, "page": 1, "pageSize": 20, "totalPages": 1, "hasNextPage": false, "hasPrevPage": false }, "grouped": true }
}totalItems: 3 describes the response you got back — there are 3 rows in data, one per severity — not the 500 records that fed into them. Each row's own count (200, 150, 150) is how many of those 500 records landed in that one group; summing the three count values recovers the 500, but that's a property of the data, not something totalItems ever reports. totalItems only equals the raw record count when there's no groupBy at all (the raw-data case above).
A response metric's key is always the field name — sum and avg both use it as-is, with no suffix. If a single call aggregates the same field with two different functions (e.g. both sum and avg of cost), the keys collide — split that into two groupBy calls instead.
Multiple group fields, a mixed aggregation, and a row count together:
{
"groupBy": {
"fields": ["region", "status"],
"metrics": [
{ "field": "total" },
{ "field": "cpuPercent", "fn": "avg" }
],
"count": true
}
}{
"data": [
{ "fields": { "region": "EU", "status": "shipped" }, "metrics": { "total": 4820.00, "cpuPercent": 62.3 }, "count": 108 },
{ "fields": { "region": "US", "status": "shipped" }, "metrics": { "total": 11250.00, "cpuPercent": 54.1 }, "count": 240 }
]
}1.6.3 Contract rules
- Omit
groupBy(or send nothing) → raw rows indata. groupBypresent → eachdataitem is{ fields, metrics }, pluscountwhen requested — never a flat resource row.- Always include
meta.grouped: boolean. - At least one of
groupBy.metrics(non-empty) orgroupBy.count: trueis required whengroupByis present — missing both is a400/422validation error. metrics[].fnis one ofsum(default) oravg.countis not a metric function — it's the separategroupBy.countboolean.- Response metric keys are always the plain
fieldname for bothsumandavg.count, if requested, is a top-level sibling offields/metrics, not nested insidemetrics. - One
/searchendpoint per resource handles both raw and grouped results — never a separate/groupByroute.
1.7 Filters — Distinct Filter Values
An optional companion endpoint per resource, for populating filter UIs: given a filter context, return the distinct values available for one or more fields.
POST /resource/filters
Request
{
"filter": [ { "field": "status", "value": "shipped" } ],
"fields": ["region", "provider"]
}Response
{
"data": {
"region": ["EU", "US", "APAC"],
"provider": ["aws", "azure", "gcp"]
}
}| Field | Description |
|---|---|
filter | Optional. Scopes the distinct-values results to the same conditions a /search call would use (e.g. "what regions exist among shipped orders"), so filter dropdowns can narrow their own options contextually. |
fields | Required, non-empty. The fields to return distinct values for. |
1.8 Standard Success Response Envelope
Every successful response — raw list or grouped — uses the same top-level shape:
{
"data": { /* array (raw rows or grouped rows) */ },
"meta": { /* pagination, grouped, groupBy, etc. */ }
}Single resource (non-search endpoints, e.g. GET /users/{id}):
{
"data": { "id": "123", "type": "user", "attributes": { "name": "Jane Doe" } }
}1.9 Standard Error Response Envelope
{
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more fields failed validation.",
"status": 422,
"details": [
{ "field": "groupBy", "issue": "must set a non-empty metrics array, count: true, or both" },
{ "field": "pagination.pageSize", "issue": "must be <= 100" }
],
"requestId": "req_9f8a2c1e",
"timestamp": "2026-07-29T14:32:00Z"
}
}| Field | Description |
|---|---|
code | Stable, machine-readable string (see table below) |
message | Human-readable summary |
status | HTTP status code, duplicated in the body for convenience |
details | Optional array of field-level errors — field uses a JSON-path-like string since fields live in a nested body |
requestId | Correlates client reports to server logs |
timestamp | ISO 8601 UTC |
The HTTP status code on the response must match status in the body — never return 200 with an embedded error object. Clients branch on the HTTP status and code, not on response body shape alone.
1.10 Standard Error Codes
| HTTP Status | code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | Malformed request body (invalid JSON, unknown field) |
| 401 | UNAUTHORIZED | Missing or invalid authentication |
| 403 | FORBIDDEN | Authenticated but not permitted |
| 404 | NOT_FOUND | Resource does not exist |
| 405 | METHOD_NOT_ALLOWED | HTTP method not supported on this route |
| 409 | CONFLICT | State conflict (e.g. duplicate resource) |
| 410 | GONE | Resource existed but was permanently removed |
| 413 | PAYLOAD_TOO_LARGE | Request body exceeds size limit |
| 422 | VALIDATION_ERROR | Semantically invalid input (field-level errors) |
| 429 | RATE_LIMITED | Too many requests |
| 500 | INTERNAL_ERROR | Unexpected server error |
| 502 | BAD_GATEWAY | Upstream dependency failure |
| 503 | SERVICE_UNAVAILABLE | Service temporarily down / overloaded |
| 504 | GATEWAY_TIMEOUT | Upstream dependency timeout |
Guideline: code values should be stable across API versions even if message text changes — clients should branch on code, never on message.
1.11 Quick Reference — Combined Examples
Raw data request/response
POST /orders/search{
"filter": [ { "field": "status", "value": "shipped" } ],
"search": { "query": "acme corp" },
"sort": [ { "field": "createdAt", "direction": "desc" } ],
"pagination": { "page": 1, "pageSize": 20 }
}{
"data": [
{ "id": "ord_1", "status": "shipped", "region": "EU", "total": 420.00, "createdAt": "2026-07-20T10:00:00Z" }
],
"meta": {
"pagination": { "page": 1, "pageSize": 20, "totalItems": 42, "totalPages": 3, "hasNextPage": true, "hasPrevPage": false },
"grouped": false,
"appliedFilters": [ { "field": "status", "value": "shipped" } ],
"search": "acme corp",
"sort": [ { "field": "createdAt", "direction": "desc" } ]
}
}Grouped data request/response
POST /orders/search{
"filter": [ { "field": "status", "value": "shipped" } ],
"groupBy": { "fields": ["region"], "metrics": [ { "field": "total" } ] },
"pagination": { "page": 1, "pageSize": 20 }
}{
"data": [
{ "fields": { "region": "EU" }, "metrics": { "total": 4820.00 } },
{ "fields": { "region": "US" }, "metrics": { "total": 11250.00 } }
],
"meta": {
"pagination": { "page": 1, "pageSize": 20, "totalItems": 2, "totalPages": 1, "hasNextPage": false, "hasPrevPage": false },
"grouped": true,
"groupBy": { "fields": ["region"], "metrics": [ { "field": "total" } ] },
"appliedFilters": [ { "field": "status", "value": "shipped" } ]
}
}Range filter + count groupBy request/response
POST /resources/search{
"filter": [
{ "field": "cost", "op": "gte", "value": 1000 },
{ "field": "provider", "value": "aws" }
],
"groupBy": { "fields": ["severity"], "count": true }
}{
"data": [
{ "fields": { "severity": "high" }, "count": 42 },
{ "fields": { "severity": "medium" }, "count": 108 }
],
"meta": { "grouped": true, "groupBy": { "fields": ["severity"], "count": true } }
}1.12 Why POST Instead of GET
- Body size & structure: filter lists, group-by fields, and metrics don't fit cleanly or safely into a URL/query string (length limits, encoding issues).
- Caching: GET responses are cacheable by intermediaries by default; POST is not, which is usually correct here since results depend on caller-specific filters.
- Idempotency note: these POST calls are safe/idempotent reads even though the verb is POST — document this explicitly (e.g. in API docs or an
X-Idempotent: trueheader) so clients and gateways don't assume side effects.
2. Other APIs (Planned)
Guidelines for other operation types — create, update, delete, and any other non-search endpoint — will be added here as their own numbered top-level sections (e.g. "2. Update API", "3. Delete API"). Until they land, those operations follow ordinary REST semantics (POST/PUT/PATCH/DELETE on a specific resource, single-resource GET /resource/{id}, etc.) rather than anything in Section 1, which governs list/search operations only.