Guide
How to design a search API
Every search endpoint faces the same five decisions, and most of them get made by accident. This is what each one costs, and what the OpenPredicate specification settles so you do not have to settle it again.
The five decisions
- Where the filter goes — query string, request body, or a
QUERYbody. - What the filter says — a bespoke string, or a grammar with a schema.
- How it is described — to a developer through OpenAPI, to a model
through an MCP
inputSchema. - What this endpoint actually serves — because none serves the whole grammar over every field.
- How you say no — precisely enough that the client can fix it.
1. Where the filter goes
A query string is fine while filters stay flat. The moment someone needs available cats and dogs, or any rescue born since 2020, there is no natural way to write that as key–value pairs, and the usual response is to invent a small language inside one parameter:
GET /pets?q=status:open AND (species:cat OR tags:rescue)That string is now an API surface with no schema, no validator and no generated client. The alternative is a JSON body, which nests for free and can be described by a schema:
{
"filter": {
"$and": [
{ "status": "available" },
{ "born": { "$gte": "2020-01-01" } }
]
},
"sort": [{ "field": "born", "direction": "desc" }],
"limit": 50,
"cursor": "eyJvZmZzZXQiOjUwfQ"
}
Note what is and is not in there. The filter is one member; sorting,
pagination and limits are siblings. That separation is deliberate — a predicate is the part
every search endpoint has in common, while result shaping differs per resource. OpenPredicate
specifies the value of filter and nothing around it, which is what lets one
grammar serve endpoints whose results have nothing else in common.
On the method. POST for search is a compromise: it is neither
safe nor cacheable, which is why intermediaries cannot help you.
RFC 10008 defines
QUERY — a method that takes a body and is safe and cacheable. Where your
stack supports it, it is the right answer; where it does not,
POST /…/search is the conventional fallback. Keep the body identical between them.
2. What the filter says
Once the filter is JSON, it needs a grammar. The parts that decide whether that grammar survives contact with a real API are rarely the operators:
| The question | Why it bites later |
|---|---|
| What happens when a field is missing or null? | Leave it unstated and every implementation picks differently, so the same filter returns different rows on different backends. |
How do AND and OR nest? |
Precedence invented per client is precedence argued about in review forever. |
Is "2020-01-01" > 5 an error or a false? |
Silent coercion turns a typo into a wrong answer instead of a rejection. |
| What bounds nesting depth? | Without a stated limit, a filter is a denial-of-service vector. |
| How does a client know a filter is valid before sending it? | If the grammar is not a schema, it cannot. Every check moves to your server. |
OpenPredicate answers these in the specification: evaluation is three-valued, so a comparison against a missing field is UNKNOWN and only TRUE matches; nesting is explicit rather than precedence-based; coercion is defined; and there are stated safety limits. The grammar itself is one JSON Schema file with no dependencies, so a client can validate before sending.
The one that surprises everyone.
{"status": {"$ne": "archived"}} does not match a record that has no
status at all. Not-equal against a missing field is UNKNOWN, and UNKNOWN is not a
match. This is SQL's behaviour; the point of writing it down is that
$unknownAs then lets a
clause opt out on purpose rather than by accident.
3. How it is described
A search endpoint now has two audiences — developers reading an OpenAPI document, and models
being handed a tool. Because the grammar is a JSON Schema, both are a $ref.
In an OpenAPI document
Reference the schema from the request body. The filter becomes part of the contract: it validates in CI, and generated clients get types for the one part users most often get wrong.
paths:
/pets/search:
post:
summary: Search pets
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [filter]
properties:
filter:
$ref: 'https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json'
limit:
type: integer
maximum: 200
The failure mode to avoid is typing filter as a bare object, or as a
string holding a bespoke expression. Either way the document describes the
envelope and says nothing about the contents.
As an MCP tool's inputSchema
A tool that takes query: string makes the model guess a syntax and leaves you
parsing whatever it guessed. A tool whose inputSchema spells out the grammar
gives the model something to aim at and gives you something to validate against before the
filter reaches a database.
{
"name": "search_pets",
"description": "Find pets matching a filter.",
"inputSchema": {
"type": "object",
"required": ["filter"],
"properties": {
"filter": { "$ref": "https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json" },
"limit": { "type": "integer", "maximum": 200 }
}
}
}
Every operator in the schema carries a description, so the instructions travel
with the contract instead of being duplicated into a prompt that drifts. The
operator reference is generated from those same descriptions — it is
the grammar describing itself.
4. What this endpoint actually serves
No endpoint serves the whole grammar over every field. Regular expressions over an unindexed column are a table scan; a date range on a partition key is cheap. So a server has to say which slice it serves, and the honest place for that is a capability document rather than prose in a wiki.
{
"queryLanguage": "https://openpredicate.tech/schema/v0.4.0/open-predicate-schema.json",
"profiles": ["core", "strings", "ranges"],
"fields": {
"status": {
"operators": ["$eq", "$ne", "$in"],
"type": "string",
"values": ["available", "pending", "sold"]
},
"born": {
"operators": ["$gt", "$gte", "$lt", "$lte", "$between"],
"type": "string",
"format": "date-time"
}
}
}
This is what lets a client narrow a filter before sending it, and it is the document
you hand a model as context. In OpenPredicate the unit a server advertises is the
profile: core is required of every implementation, the
rest are optional and named. Serving only part of a profile is allowed — advertising it in
that case is not.
5. How you say no
A filter you cannot serve deserves a better answer than 400 Bad Request. The
client's next move depends entirely on why: a malformed filter is a bug to fix, an
unknown field may be a typo, and an unsupported operator means fall back to a different query.
Collapsing all three into one status forces a guess.
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://openpredicate.tech/problems/unsupported-operator",
"title": "Unsupported operator",
"status": 400,
"detail": "The operator $regex is not served on this endpoint.",
"pointer": "/filter/$and/1/title/$regex"
}
OpenPredicate names five conditions and gives each a type URI that
dereferences to its own explanation — paste it from a log and you land on the page. The
pointer is an RFC
6901 JSON Pointer into the request body, because a client facing a dozen nested clauses
otherwise has no way to know which one to change.
A checklist
- The filter is a JSON object in the body, not a bespoke string in a query parameter.
- Filtering is separate from sorting, pagination and projection.
- The grammar is a schema you can
$ref, and it is pinned by version. - Missing-field behaviour is written down, not left to each implementation.
- There is a stated bound on nesting depth and filter size.
- The endpoint publishes which operators and fields it accepts.
- A rejection says which of a known set of conditions applies, and points at the clause.
- The same grammar serves your HTTP API and your agent tooling.
If you would rather not decide all of that yourself, that list is what
the specification is. You can
try a filter in the browser against the real grammar, or
$ref the schema and start.
Questions people actually ask
Should search filters go in the query string or the request body?
In the body, once the filter can nest. A query string has no natural way to express (a AND b) OR c, so APIs that start there end up inventing a mini-language inside a string — ?q=status:open AND born>2020 — which no schema can validate and no client can build safely. A JSON body nests for free. The cost is that POST is not obviously cacheable or idempotent, which is exactly the gap RFC 10008's QUERY method closes: a method with a body that is safe and cacheable. Use QUERY where you can, POST /…/search where you cannot.
Should a search endpoint be GET or POST?
GET for the simple, flat cases — a handful of equality filters that fit in a query string and benefit from HTTP caching. POST /…/search once filters nest or grow past URL length limits. The honest answer is that neither is right, because GET has no body and POST is not safe; QUERY exists because the working group agreed. If you support two, make the filter grammar identical across both so a client does not have to learn twice.
How do I describe a search filter in an OpenAPI document?
Point the filter property at a JSON Schema with $ref. If the grammar is one self-contained schema, that is a single line, it validates in CI, and your generated clients get types for it. What you should not do is type the filter as object with no further constraint, or as a string — both mean your document describes the endpoint's shape while saying nothing about the part clients get wrong most.
What should an MCP tool's inputSchema be for a search tool?
A JSON Schema that spells out the filter grammar, rather than a free-text query string. A model writing into a typed schema can be told exactly which operators exist and what each one means, and the result can be validated before it reaches your database. A free-text field pushes the parsing problem onto you and gives the model nothing to aim at. Every operator in OpenPredicate carries a description, so those descriptions become the tool's instructions.
How should a search API reject a filter it cannot serve?
With 400, a machine-readable reason, and a pointer to the clause at fault. "Bad request" with no reason forces the client to guess whether the filter was malformed, referenced an unknown field, or used an operator this endpoint does not serve — three problems with three different fixes. OpenPredicate defines five conditions and recommends RFC 9457 Problem Details with an RFC 6901 JSON Pointer.
How does a client know which fields and operators a search endpoint supports?
It has to be told, because no endpoint serves the whole grammar over every field. Publish a capability document — profiles served, and per field the operators and domain allowed. A client can then narrow a filter before sending it, and a model can be handed the same document as context. OpenPredicate makes profiles the unit a server advertises, and accepting only part of a profile is allowed while advertising it in that case is not.
Why does my filter match fewer rows than expected when a field is null?
Because comparison against a missing or null field is neither true nor false. OpenPredicate is explicit about this: evaluation is three-valued — TRUE, FALSE, UNKNOWN — and only TRUE matches. So {"status": {"$ne": "archived"}} does not match a record with no status at all, which surprises almost everyone the first time. That is SQL's behaviour too; the difference is that it is written down, and $unknownAs lets a clause opt out deliberately.
Should I write my own filter grammar or adopt one?
Writing one is a week; maintaining it is the rest of the project. The parts that look small up front are where the cost is — precedence, null handling, type coercion, a safety limit on nesting depth, an error model precise enough to act on, and a description a client generator can read. If you adopt one, the test is whether it is a plain JSON Schema you can $ref, whether it pins by version, and whether it says what happens when a field is missing.