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=mlops rather than free-text search; structured filters are cacheable and predictable.
  • Cursor pagination: A pagination scheme where the client receives an opaque next_cursor token to fetch the next page, rather than computing offsets that drift as the catalog mutates.
  • Conditional read: A request that returns 304 Not Modified when the client's cached ETag matches 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
Loading diagram...

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. hashlib and json are used to compute a weak ETag over the result set.
  • Lines 13–22: ListServicesQuery binds query parameters into a typed object. Every filter is optional, validated for length, and page_size is 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_estimate field 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 ListServicesQuery instance.
  • Lines 38–46: Each filter is conditionally applied to the SQLAlchemy query. The ilike pattern 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_id as the key. Because service_id is unique and we order by it, the cursor cleanly resumes from where the previous page ended. Concurrent inserts of new services with service_id lexicographically 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 + 1 rows. If we received an extra row, there is at least one more page; we encode the last in-page row's service_id as the next cursor and trim the extra row off the response.
  • Lines 64–72: The weak ETag header summarizes the page contents. A weak ETag (prefixed with W/) 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-Match as an HTTP header. FastAPI's Header dependency normalizes the header name (if-none-matchIf-None-Match) and exposes it as a regular Python parameter.
  • Lines 10–12: A missing entry raises HTTPException(404). The detail string includes the requested service_id so client logs are immediately actionable.
  • Lines 14–18: The ETag is composed of the entry's semantic version and the unix timestamp of the last update. Any change to either field produces a different tag. When the client sends a matching If-None-Match, the server returns 304 Not Modified with 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 ILIKE against 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.

  1. 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 Sunset header.
  2. 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.
  3. 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.
  4. Always return an ETag. Even on listing responses. The cost of computing one is trivial compared to the bandwidth saved by 304 responses.
  5. 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.
  6. Document deprecations in the OpenAPI schema. FastAPI's deprecated=True attribute 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.