routes

FastAPI route modules registered on the main app. Public reads (/apps, /health) are open; admin writes (/admin/*) are gated by a shared secret and rate-limited per IP.

apps

Public catalog reads. List + filter, per-slug fetch, per-source payloads, drift detection, and label generation.

async list_apps(vendor: str | None = None, source: str | None = None, exclude_source: str | None = None, limit: int = Query(100), offset: int = Query(0), session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) list[App][source]

List apps in the catalog with optional filters and pagination.

All filters (vendor, source, exclude_source) and the limit/offset pagination push down into a single SQL statement so the database does the filtering before paginating. Earlier versions of this endpoint filtered source/exclude_source in Python after materializing every row that matched vendor, which made limit describe the post-fetch slice rather than the actual page size.

Results are ordered by slug so pagination is deterministic across requests.

Parameters:
  • vendor (str | None) – Case-insensitive exact vendor match. None disables.

  • source (str | None) – Include only rows whose sources contains this token.

  • exclude_source (str | None) – Drop rows whose sources contains this token.

  • limit (int) – Maximum rows to return. Default 100, max 1000.

  • offset (int) – Number of filtered rows to skip before returning. Default 0.

  • session (AsyncSession)

Return type:

list[App]

async list_drift(vendor: str | None = None, source: str | None = None, limit: int = Query(100), offset: int = Query(0), session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) DriftResponse[source]

Scan the catalog for cross-source version drift.

Iterates apps with at least two versioned sources, compares their reported versions, and returns those whose sources disagree on what “latest” means. Drift is computed in Python after a single SQL fetch (drift cannot be expressed as a SQL filter without materializing the JSON payloads). total_scanned reflects the number of apps with enough sources to assess drift; total_with_drift is the unpaged count of disagreements.

Parameters:
  • vendor (str | None) – Case-insensitive exact vendor match. None disables.

  • source (str | None) – When set, drop entries where this source did not participate in the disagreement.

  • limit (int) – Max entries on this page. Default 100, max 1000.

  • offset (int) – Entries to skip before the page. Default 0.

  • session (AsyncSession)

Return type:

DriftResponse

async get_app(slug: str, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) App[source]
Parameters:
  • slug (str)

  • session (AsyncSession)

Return type:

App

async get_app_sources(slug: str, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) AppSources[source]
Parameters:
  • slug (str)

  • session (AsyncSession)

Return type:

AppSources

async get_app_drift(slug: str, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) DriftEntry | None[source]

Drift detection for a single app.

Returns null when the app exists but has no drift (either fewer than two versioned sources, or every source agrees). 404 if the slug is unknown.

Parameters:
  • slug (str) – URL-friendly app identifier.

  • session (AsyncSession)

Return type:

DriftEntry | None

async generate_label(slug: str, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) GenerateLabelResponse[source]

Generate an Installomator label for slug.

Projects the app’s Homebrew Cask + Installomator source payloads into an Installomator label fragment that consumers can drop into their Installomator deployments. Returns the label plus provenance metadata (which sources contributed) and any warnings about fields that couldn’t be resolved (most commonly expectedTeamID for Cask-only apps).

Parameters:
  • slug (str) – URL-friendly app identifier.

  • session (sqlalchemy.ext.asyncio.AsyncSession) – Async SQLAlchemy session (injected).

Raises:

HTTPException – 404 if slug doesn’t exist; 422 if the app has no source detail attached (rare — usually a leftover seed record).

Returns:

The generated label content + metadata.

Return type:

patcher_api.schemas.labels.GenerateLabelResponse

admin

Write surface used by the macOS resolver runner to push resolved label values back into the catalog. Token-gated and fail-closed when no token is configured.

class ResolvedIngestSummary(*, received: int, updated: int, skipped_not_ok: int, skipped_invalid: int, skipped_unknown: int, malformed_lines: int)[source]

Per-upload accounting returned to the runner so a CI step can assert on it.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Parameters:
  • received (int)

  • updated (int)

  • skipped_not_ok (int)

  • skipped_invalid (int)

  • skipped_unknown (int)

  • malformed_lines (int)

class UnresolvedLabels(*, labels: list[str])[source]

Worklist the macOS runner fetches: label names that need macOS resolution.

Create a new model by parsing and validating input data from keyword arguments.

Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.

self is explicitly positional-only to allow self as a field name.

Parameters:

labels (list[str])

require_admin(authorization: str | None = Header(None)) None[source]

Gate a route on the shared admin secret.

Fail-closed: an unset PATCHER_API_ADMIN_TOKEN returns 503 (endpoint disabled) rather than allowing the request, so forgetting to configure the secret can never silently open the write surface. compare_digest keeps the check constant-time.

Parameters:

authorization (str | None)

Return type:

None

async list_unresolved_labels(session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) UnresolvedLabels[source]

The macOS runner’s worklist: labels the Linux resolver couldn’t resolve, plus the ones macOS already owns (re-resolved each run to stay fresh).

The read half of the resolver handshake — the runner GETs this, resolves only these labels, and POSTs the results back to /labels/resolved. This keeps each runner pass to the ~gap Linux can’t cover instead of every label.

Parameters:

session (AsyncSession)

Return type:

UnresolvedLabels

async ingest_resolved_labels(request: Request, session: AsyncSession = Depends(dependency=<function get_session>, use_cache=True, scope=None)) ResolvedIngestSummary[source]

Ingest macOS-resolved label values from an NDJSON stream.

Body is application/x-ndjson: one resolveLabel.sh --json record per line. For each record we update only the download_url / app_new_version of the label the Linux ingest already created — never creating rows (the Linux ingest is the sole owner of row existence and the structural columns). The rules:

  • ok:false records are skipped, so a failed vendor scrape never clobbers a previously-good value.

  • each value must pass the same sanity check the API serves through (looks_like_clean_http_url() / looks_like_clean_version()); a field that fails is dropped, not written.

  • rows updated are stamped resolution_source="macos" + resolved_at so the Linux refresh defers to them while they’re fresh.

After the batch the catalog is re-stitched (so /apps reflects the new values) and the ETag recomputed (so caches don’t pin to the pre-upload hash).

Parameters:
  • request (Request)

  • session (AsyncSession)

Return type:

ResolvedIngestSummary