Free lesson
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.
~25 min read · Free to read — no subscription required.
Build the service catalog REST API with search and filtering
The service catalog data model and the golden path registry are useless until platform consumers can reach them through a stable, well-documented HTTP surface. The REST API is the contract between the catalog's internal storage and every other system that needs to discover services—the developer portal, the provisioning orchestrator, the cost dashboard, the tenancy controller, even external systems like the SRE on-call rotation tool. A poorly designed catalog API is the single biggest source of platform friction: ambiguous URL shapes break client caching, missing pagination melts servers when teams list every service, and inconsistent filter semantics force every consumer to write custom client logic.
This section walks through the FastAPI implementation that exposes the catalog and the golden path registry to the rest of the platform. The patterns covered here—resource-oriented URLs, structured query parameters, paged responses, and conditional reads via ETag—are not academic. They are the patterns that survive contact with hundreds of teams cloning the API client into their own services.
Key Terminology
- Resource-oriented URL: A URL whose path identifies a noun (a service entry, a golden path) rather than an action; verbs are conveyed by the HTTP method.
- Structured filter: A query parameter expressed as
?service_type=model-serving&owner_team=mlopsrather than free-text search; structured filters are cacheable and predictable. - Cursor pagination: A pagination scheme where the client receives an opaque
next_cursortoken to fetch the next page, rather than computing offsets that drift as the catalog mutates. - Conditional read: A request that returns
304 Not Modifiedwhen the client's cachedETagmatches the server's current resource version, eliminating wasted bandwidth. - Listing endpoint: A GET endpoint that returns multiple resources, always paged, always filterable, and never returning the full population in a single response.
API surface and URL shape
Every catalog operation maps to one of five HTTP verbs against one of two resource collections. The shape below is enforced by the FastAPI router definitions later in this section. Resist the urge to add ?action=... query parameters or /services/{id}/deploy action endpoints; once those land, every consumer client has to special-case them.
Code snippet
1GET /api/catalog/services → list services with filters and pagination 2GET /api/catalog/services/{service_id} → fetch a single service entry 3POST /api/catalog/services → register a new service entry 4PATCH /api/catalog/services/{service_id} → partial update of an existing entry 5DELETE /api/catalog/services/{service_id} → soft-delete (sets deprecated=true) 6 7GET /api/catalog/golden-paths → list golden paths with persona filter 8GET /api/catalog/golden-paths/{path_id} → fetch a single golden path 9POST /api/catalog/golden-paths → register a new golden path
The router validates each request against Pydantic models, delegates persistence to a repository class, and writes an ETag header on every response so downstream clients can implement conditional reads. Redis caches hot listing queries with a 30-second TTL; PostgreSQL is the source of truth.
Listing endpoint: filtering, pagination, sort
The list endpoint is the most frequently called catalog operation and the easiest to get wrong. The implementation below uses three primitives: a structured query model that turns query parameters into a typed object, a cursor-based pagination scheme that survives concurrent writes, and a deterministic sort order so that two clients listing the same filter see the same ordering.
Code snippet python
1from fastapi import APIRouter, Query, Depends, Response, status 2from pydantic import BaseModel, Field 3from typing import Optional 4from sqlalchemy.orm import Session 5from app.models import ServiceCatalogEntry, ServiceType, ServiceTier 6from app.deps import get_db, encode_cursor, decode_cursor 7import hashlib 8import json 9 10router = APIRouter(prefix="/api/catalog", tags=["catalog"]) 11 12class ListServicesQuery(BaseModel): 13 service_type: Optional[ServiceType] = None 14 tier: Optional[ServiceTier] = None 15 owner_team: Optional[str] = Field(default=None, max_length=64) 16 name_contains: Optional[str] = Field(default=None, max_length=64) 17 include_deprecated: bool = False 18 cursor: Optional[str] = None 19 page_size: int = Field(default=25, ge=1, le=100) 20 21class ServiceListResponse(BaseModel): 22 items: list[ServiceCatalogEntry] 23 next_cursor: Optional[str] 24 total_estimate: int 25 26@router.get("/services", response_model=ServiceListResponse) 27def list_services( 28 response: Response, 29 q: ListServicesQuery = Depends(), 30 db: Session = Depends(get_db), 31) -> ServiceListResponse: 32 stmt = db.query(ServiceCatalogEntry) 33 if q.service_type: 34 stmt = stmt.filter(ServiceCatalogEntry.service_type == q.service_type) 35 if q.tier: 36 stmt = stmt.filter(ServiceCatalogEntry.tier == q.tier) 37 if q.owner_team: 38 stmt = stmt.filter(ServiceCatalogEntry.owner_team == q.owner_team) 39 if q.name_contains: 40 stmt = stmt.filter(ServiceCatalogEntry.name.ilike(f"%{q.name_contains}%")) 41 if not q.include_deprecated: 42 stmt = stmt.filter(ServiceCatalogEntry.deprecated == False) # noqa: E712 43 44 if q.cursor: 45 last_id = decode_cursor(q.cursor) 46 stmt = stmt.filter(ServiceCatalogEntry.service_id > last_id) 47 48 stmt = stmt.order_by(ServiceCatalogEntry.service_id) 49 rows = stmt.limit(q.page_size + 1).all() 50 51 next_cursor = None 52 if len(rows) > q.page_size: 53 next_cursor = encode_cursor(rows[q.page_size - 1].service_id) 54 rows = rows[: q.page_size] 55 56 body = ServiceListResponse( 57 items=rows, next_cursor=next_cursor, total_estimate=len(rows), 58 ) 59 etag = hashlib.sha256( 60 json.dumps([r.service_id for r in rows], sort_keys=True).encode() 61 ).hexdigest()[:16] 62 response.headers["ETag"] = f'W/"{etag}"' 63 return body
- Lines 1–10: Import the FastAPI primitives, the Pydantic catalog models defined alongside this section's lab, and helper functions for cursor encoding.
hashlibandjsonare used to compute a weakETagover the result set. - Lines 13–22: ListServicesQuery binds query parameters into a typed object. Every filter is optional, validated for length, and
page_sizeis bounded between 1 and 100 to protect the server from a client requesting tens of thousands of rows in one call. - Lines 25–28: ServiceListResponse wraps the page in a stable shape: an items array, an opaque cursor token, and a
total_estimatefield that downstream UIs can use to render approximate result counts without forcing the database to compute exact counts on every page. - Lines 31–37: The list_services endpoint declares its query model as a FastAPI dependency, which automatically translates query string parameters into a validated
ListServicesQueryinstance. - Lines 38–46: Each filter is conditionally applied to the SQLAlchemy query. The
ilikepattern for name_contains is case-insensitive but should be backed by a trigram index in PostgreSQL for any catalog larger than a few hundred entries—without the index, every list request scans the full table. - Lines 48–53: Cursor pagination uses the
service_idas the key. Becauseservice_idis unique and we order by it, the cursor cleanly resumes from where the previous page ended. Concurrent inserts of new services withservice_idlexicographically before the cursor do not affect the in-progress pagination, which is the failure mode that offset pagination cannot survive. - Lines 56–62: The query fetches
page_size + 1rows. If we received an extra row, there is at least one more page; we encode the last in-page row'sservice_idas the next cursor and trim the extra row off the response. - Lines 64–72: The weak
ETagheader summarizes the page contents. A weak ETag (prefixed withW/) signals to caches that semantically equivalent representations may share the tag, which is appropriate when the server is allowed to choose its own ordering for ties.
Single-resource read with conditional response
Single-resource reads are dominated by repeated lookups: the provisioning orchestrator may request the same qdrant-vector-db entry hundreds of times during a deployment burst. A conditional read collapses the response body when the client already has the latest version cached, which moves load off the database without requiring any client-side cache management.
Code snippet python
1from fastapi import HTTPException, Header 2 3@router.get("/services/{service_id}", response_model=ServiceCatalogEntry) 4def get_service( 5 service_id: str, 6 response: Response, 7 if_none_match: Optional[str] = Header(default=None), 8 db: Session = Depends(get_db), 9) -> ServiceCatalogEntry: 10 entry = db.query(ServiceCatalogEntry).filter_by(service_id=service_id).first() 11 if entry is None: 12 raise HTTPException(status_code=404, detail=f"service '{service_id}' not found") 13 14 etag = f'W/"{entry.version}-{int(entry.updated_at.timestamp())}"' 15 if if_none_match and if_none_match == etag: 16 response.status_code = status.HTTP_304_NOT_MODIFIED 17 return None 18 response.headers["ETag"] = etag 19 return entry
- Lines 3–9: The endpoint accepts
If-None-Matchas an HTTP header. FastAPI'sHeaderdependency normalizes the header name (if-none-match↔If-None-Match) and exposes it as a regular Python parameter. - Lines 10–12: A missing entry raises
HTTPException(404). The detail string includes the requestedservice_idso client logs are immediately actionable. - Lines 14–18: The
ETagis composed of the entry's semanticversionand the unix timestamp of the last update. Any change to either field produces a different tag. When the client sends a matchingIf-None-Match, the server returns304 Not Modifiedwith no body, which is the cheapest possible response.
Search across catalog and golden paths
Platform consumers regularly need to find services and golden paths together—"every model-serving thing my team owns, plus every golden path that deploys one." A unified search endpoint composes both repositories into a single query surface, which avoids forcing the developer portal to make two parallel requests and reconcile the results.
Code snippet python
1class CatalogSearchResult(BaseModel): 2 services: list[ServiceCatalogEntry] 3 golden_paths: list[GoldenPathDefinition] 4 5@router.get("/search", response_model=CatalogSearchResult) 6def search_catalog( 7 text: str = Query(min_length=2, max_length=64), 8 owner_team: Optional[str] = None, 9 db: Session = Depends(get_db), 10) -> CatalogSearchResult: 11 services_q = db.query(ServiceCatalogEntry).filter( 12 ServiceCatalogEntry.name.ilike(f"%{text}%") 13 | ServiceCatalogEntry.description.ilike(f"%{text}%") 14 ) 15 if owner_team: 16 services_q = services_q.filter(ServiceCatalogEntry.owner_team == owner_team) 17 services = services_q.limit(20).all() 18 19 paths_q = db.query(GoldenPathDefinition).filter( 20 GoldenPathDefinition.name.ilike(f"%{text}%") 21 | GoldenPathDefinition.description.ilike(f"%{text}%") 22 ) 23 paths = paths_q.limit(20).all() 24 25 return CatalogSearchResult(services=services, golden_paths=paths)
- Lines 1–3: CatalogSearchResult packages both result types in a single response. Keeping them in distinct arrays preserves the type information that the UI uses to render different cards for services versus golden paths.
- Lines 6–11: The search_catalog endpoint requires a minimum query length of 2 characters to avoid expensive full-table scans on empty queries, and caps the maximum length at 64 to keep query plans bounded.
- Lines 12–18: Service search performs an
ILIKEagainst both name and description with a single OR-combined predicate. PostgreSQL's GIN trigram indexes (pg_trgm) make this efficient even on large catalogs; without trigram indexing, switch the predicate to a tsvector full-text search. - Lines 20–26: Golden path search uses the same shape against the path registry. The hard cap at 20 results per kind keeps the response small enough that the developer portal can render it inline without virtualization.
Operating discipline
The REST API is a long-lived contract. Every change to its shape ripples through every team that has cloned the client. Treat the rules below as non-negotiable defaults that you only break with platform-wide approval.
- Never break the URL shape. Adding a new optional query parameter is safe. Renaming an existing one is not. If you must rename, add the new name, dual-publish for two release cycles, then deprecate the old name with a
Sunsetheader. - Always paginate listings, even when the data is small today. A catalog that has 12 services today will have 1,200 services in two years. Endpoints that omitted pagination at v1 cannot retrofit it without breaking every consumer's loop.
- Filter parameters must be structured. Free-text search is a separate endpoint. Mixing them encourages clients to over-filter on the wrong path and bypasses index optimization.
- Always return an
ETag. Even on listing responses. The cost of computing one is trivial compared to the bandwidth saved by304responses. - Never expose internal database identifiers. All API IDs (
service_id,path_id) are the catalog's own external identifiers. Surfacing PostgreSQL row IDs in URLs leaks implementation details that you cannot remove later. - Document deprecations in the OpenAPI schema. FastAPI's
deprecated=Trueattribute on a route generates the appropriate field in/openapi.json, which the developer portal uses to render warnings on calls to deprecated routes.
These rules are why the service catalog API can serve hundreds of teams from a single deployment without becoming the platform's bottleneck.
Keep going with GenAI Platform Engineering
Create a free account to track your progress and open this lesson in the full learning view. Subscribe to unlock the entire path — every goal, the hands-on labs, quizzes, and your verifiable skill graph — from . Cancel anytime.