Preview goal
Build service catalog REST API with search and filtering
Create the API layer that exposes the service catalog to platform consumers. Implement search, filtering by category, and versioned catalog responses.
Free to read — no subscription required.
Build service catalog REST API with search and filtering
Introduction
When you publish a service catalog but give consumers no programmatic way to query it, teams fall back to reading raw YAML in a Git repo, screenshotting Slack messages, or asking the platform team "what vector databases do we offer?" in a ticket. The catalog data exists, but it is not discoverable—engineers cannot answer "show me every GPU-backed model-serving entry owned by team-beta, newest version first" without a human in the loop. The API layer closes this gap by exposing the catalog over HTTP with structured search, category filtering, pagination, and an explicit response version so that developer portals, CLIs, and CI pipelines can all consume the same contract. By the end of this lesson, you'll be able to define query and response schemas that validate consumer input, implement a full-text search and category filter that runs against the catalog store, and wrap results in a versioned envelope that lets you evolve the payload shape without breaking existing clients.
Key Terminology
- Catalog API: The HTTP interface that exposes read operations over the service catalog—listing, searching, filtering, and fetching individual entries—to platform consumers.
- Query Parameter Model: A validated schema (
CatalogQuery) that binds URL query string arguments to typed fields, rejecting malformed requests before any store access. - Response Envelope: A wrapper object (
CatalogListResponse) that carries the result items alongside metadata such as total count, pagination cursor, and the API version. - API Versioning: Embedding an explicit
api_versionfield in every response so consumers can detect and adapt to schema changes rather than silently breaking. - Category Filter: A server-side predicate that narrows results to entries matching a
ServiceCategory, applied before pagination so counts stay accurate.
Concepts
Now that we have a catalog data model to expose, the API layer sits between platform consumers and the underlying entry store, translating loosely-typed HTTP query strings into validated, filtered, versioned responses. A request flows through four stages: input binding, filtering, pagination, and envelope construction. Each stage has a single responsibility, and keeping them separate is what makes the search endpoint testable and the response shape evolvable.
The input binding stage maps ?q=qdrant&category=vector-db&limit=20&offset=0 onto a CatalogQuery instance. Binding through a typed model means an invalid limit (negative, or above the ceiling) is rejected with a 422 automatically—the endpoint body never runs against bad input. The filtering stage applies two independent predicates: a case-insensitive substring match against the entry's name and description for the q term, and an exact service_type match for the category. Both are optional; omitting either widens the result set rather than erroring.
Ordering matters between the last two stages. Filtering must complete before pagination so that total reflects the count of matching entries, not the count of all entries. If you paginate first and filter the page, page two of a search silently drops matches that happened to fall on page one, and the total is meaningless. Running apply_filters over the whole set, computing total from the filtered length, and only then slicing with paginate keeps the pagination metadata honest.
The response envelope is where versioning lives. Rather than returning a bare JSON array, list_services returns a CatalogListResponse carrying api_version, items, total, limit, and offset. Consumers read api_version to decide whether they understand the payload. When you later add a field—say a deprecation_notice—old clients that pinned to api_version "v1" keep working because additive changes within a version are safe, and a breaking change bumps the version so clients can branch on it. This is the same discipline you apply to the catalog entries themselves, extended to the transport layer.
Code Walkthrough
Having reviewed the four-stage request flow, the following implementation encodes it as two pieces: the request/response schemas that bind and validate the HTTP contract, and the FastAPI router that wires search, filtering, and pagination into two endpoints. The first block defines ServiceCategory, the CatalogQuery input model, the per-item CatalogItemResponse, and the versioned CatalogListResponse envelope—every field constrained so malformed requests fail at binding time rather than deep in the handler.
Code snippet python
1from pydantic import BaseModel, Field 2from enum import Enum 3from typing import Optional 4 5CATALOG_API_VERSION = "v1" 6 7class ServiceCategory(str, Enum): 8 MODEL_SERVING = "model-serving" 9 TRAINING_JOB = "training-job" 10 VECTOR_DB = "vector-db" 11 FEATURE_STORE = "feature-store" 12 MONITORING = "monitoring" 13 14class CatalogQuery(BaseModel): 15 q: Optional[str] = Field(default=None, max_length=128) 16 category: Optional[ServiceCategory] = None 17 limit: int = Field(default=25, ge=1, le=100) 18 offset: int = Field(default=0, ge=0) 19 20class CatalogItemResponse(BaseModel): 21 service_id: str 22 name: str 23 service_type: ServiceCategory 24 version: str 25 owner_team: str 26 description: str 27 deprecated: bool 28 29class CatalogListResponse(BaseModel): 30 api_version: str = CATALOG_API_VERSION 31 total: int 32 limit: int 33 offset: int 34 items: list[CatalogItemResponse]
- Lines 6-12:
ServiceCategoryreuses the same kebab-case values as the catalog's service-type taxonomy, so acategoryquery parameter that is not a real category is rejected at binding—no need to validate it by hand inside the handler. - Lines 14-18:
CatalogQueryboundslimitto[1, 100]andoffsetto≥ 0. A client asking forlimit=5000gets a 422, protecting the store from unbounded scans; both search fields default toNoneso an argument-free request returns the first page of everything. - Lines 28-33:
CatalogListResponsedefaultsapi_versiontoCATALOG_API_VERSION, so every response is self-describing;totalsits besideitemsso consumers can compute how many pages remain without a second call.
The second block implements the filtering and pagination pure functions plus the two endpoints. apply_filters runs both predicates over the full entry set; paginate slices the filtered list; list_services composes them and builds the envelope; get_service handles the single-entry lookup and raises a 404 when the service_id is unknown.
Code snippet python
1from fastapi import APIRouter, Depends, HTTPException 2 3router = APIRouter(prefix="/catalog", tags=["catalog"]) 4 5def apply_filters(entries: list, query: CatalogQuery) -> list: 6 results = entries 7 if query.category is not None: 8 results = [e for e in results if e.service_type == query.category] 9 if query.q: 10 term = query.q.lower() 11 results = [ 12 e for e in results 13 if term in e.name.lower() or term in e.description.lower() 14 ] 15 return results 16 17def paginate(entries: list, limit: int, offset: int) -> list: 18 return entries[offset : offset + limit] 19 20@router.get("", response_model=CatalogListResponse) 21def list_services(query: CatalogQuery = Depends(), store=Depends(get_catalog_store)): 22 matched = apply_filters(store.all_entries(), query) 23 page = paginate(matched, query.limit, query.offset) 24 return CatalogListResponse( 25 total=len(matched), 26 limit=query.limit, 27 offset=query.offset, 28 items=[CatalogItemResponse(**e.model_dump()) for e in page], 29 ) 30 31@router.get("/{service_id}", response_model=CatalogItemResponse) 32def get_service(service_id: str, store=Depends(get_catalog_store)): 33 entry = store.get(service_id) 34 if entry is None: 35 raise HTTPException(status_code=404, detail=f"unknown service_id: {service_id}") 36 return CatalogItemResponse(**entry.model_dump())
- Lines 5-15:
apply_filtersapplies the category predicate first (cheap exact match) then the substring search, both guarded byis not None/ truthiness so an omitted parameter is a no-op that widens results rather than filtering to nothing. - Lines 22-30:
list_servicescomputesmatchedover the whole set, derivestotalfromlen(matched)before slicing, then paginates—guaranteeing the envelope'stotalcounts matches, not the raw catalog size. - Lines 33-38:
get_servicereturns the single entry orraises anHTTPExceptionwith a 404; returningNonewould let FastAPI serialize a null body, so the explicit raise is what gives consumers an actionable error.
Verify by issuing GET /catalog?category=vector-db&q=qdrant&limit=5—the response should carry api_version: "v1", a total equal to the number of matching vector-db entries whose name or description contains "qdrant", and at most five items; then GET /catalog/does-not-exist should return 404 with the unknown service_id detail.
Do's and Don'ts
Having walked through the query schema, the filter-then-paginate pipeline, and the versioned envelope above, the following imperatives distil the API-contract and search-correctness patterns into rules you can apply directly when building your own catalog endpoints.
Do's
- ✓Do bind query strings through the
CatalogQuerymodel withDepends()— theFieldconstraints (limitin[1, 100],offset ≥ 0,categoryas aServiceCategory) reject malformed requests with a 422 beforelist_servicesruns, so no handler code ever executes against an unbounded limit or an unknown category string. - ✓Do run
apply_filtersover the full entry set and derivetotalfromlen(matched)before callingpaginate— computing the count on the filtered-but-unsliced list keeps theCatalogListResponse.totalfield honest, so consumers can page through search results without silently losing matches. - ✓Do stamp every response with
api_versionfromCATALOG_API_VERSION— a self-describing envelope lets portals and CI clients branch on the version and lets you add fields additively within"v1"while reserving a version bump for breaking payload changes.
Don'ts
- ✗Don't paginate before filtering — slicing
store.all_entries()first and then runningapply_filterson the page makestotalmeaningless and drops matches that fall outside the current window; the pipeline order (apply_filters→paginate) is what guarantees correct counts and complete result sets. - ✗Don't return a bare list from
list_services— omitting theCatalogListResponseenvelope stripsapi_version,total, andoffset, leaving consumers with no way to detect schema changes or drive pagination, which is exactly the discoverability gap the API is meant to close. - ✗Don't
return Nonefromget_servicefor a missingservice_id— a null body serializes as a 200 withnull, hiding the error;raiseing anHTTPException(status_code=404, ...)gives consumers an actionable, correctly-coded failure they can branch on.
Everything in this lesson — plus the hands-on labs, quizzes, and your full learning path.