MCMMCM DocsEngineering (Internal)
LLDAPI Guideline
v1.2 is unreleased — see v1.1 for the current stable release.

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 (or query) endpoint per resource, POST-based, e.g. POST /orders/search — body holds filter, search, sort, pagination, groupBy as top-level keys. Never query-string filtering, never GET with 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 optional op (eq/in/gte/lte/between) covers range comparisons. Don't introduce a new bespoke filter object per resource (e.g. a hand-rolled FooFilterRequest with one named field per column) — use the flat array instead.
  • groupBy combines grouping and aggregation into one { fields, metrics, count } object on the same search endpoint — metrics aggregates named fields, count is a separate boolean for the row count per group. Omit groupBy entirely 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/pageSize everywhere. 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 matches error.status — never 200 with an embedded error object.
  • Error code values come from the standard error codes table (or a documented, service-specific extension of it) and stay stable across API versions — clients branch on code, never on message.
  • Expose a POST /resource/filters endpoint 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, default sort, default search.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:

opvalue shapeMeaning
eq (default)scalarEquality — same as omitting op
in (default for array)arrayValue is one of the array
gtescalarGreater-than-or-equal
ltescalarLess-than-or-equal
between2-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.

A separate top-level field for free-text/fuzzy search, distinct from structured filter.

Request format

{
  "search": {
    "query": "john doe",
    "fields": ["name", "email"]
  }
}
FieldTypeDescription
search.querystringFree-text query
search.fieldsstring[]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 onlypage/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:

fnMeaning
sum (default)Sum of the field across the group
avgAverage 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)

FieldDescription
groupBy.fieldsArray of fields to group by (the dimensions)
groupBy.metricsArray of { field, fn } — named fields to aggregate per group; fn defaults to sum
groupBy.countBoolean, 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 in data.
  • groupBy present → each data item is { fields, metrics }, plus count when requested — never a flat resource row.
  • Always include meta.grouped: boolean.
  • At least one of groupBy.metrics (non-empty) or groupBy.count: true is required when groupBy is present — missing both is a 400/422 validation error.
  • metrics[].fn is one of sum (default) or avg. count is not a metric function — it's the separate groupBy.count boolean.
  • Response metric keys are always the plain field name for both sum and avg. count, if requested, is a top-level sibling of fields/metrics, not nested inside metrics.
  • One /search endpoint per resource handles both raw and grouped results — never a separate /groupBy route.

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"]
  }
}
FieldDescription
filterOptional. 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.
fieldsRequired, 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"
  }
}
FieldDescription
codeStable, machine-readable string (see table below)
messageHuman-readable summary
statusHTTP status code, duplicated in the body for convenience
detailsOptional array of field-level errors — field uses a JSON-path-like string since fields live in a nested body
requestIdCorrelates client reports to server logs
timestampISO 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 StatuscodeMeaning
400BAD_REQUESTMalformed request body (invalid JSON, unknown field)
401UNAUTHORIZEDMissing or invalid authentication
403FORBIDDENAuthenticated but not permitted
404NOT_FOUNDResource does not exist
405METHOD_NOT_ALLOWEDHTTP method not supported on this route
409CONFLICTState conflict (e.g. duplicate resource)
410GONEResource existed but was permanently removed
413PAYLOAD_TOO_LARGERequest body exceeds size limit
422VALIDATION_ERRORSemantically invalid input (field-level errors)
429RATE_LIMITEDToo many requests
500INTERNAL_ERRORUnexpected server error
502BAD_GATEWAYUpstream dependency failure
503SERVICE_UNAVAILABLEService temporarily down / overloaded
504GATEWAY_TIMEOUTUpstream 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: true header) 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.

On this page