apifacts 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apifacts/__init__.py +3 -0
- apifacts/api/__init__.py +5 -0
- apifacts/api/app.py +487 -0
- apifacts/api/facts_index.py +286 -0
- apifacts/api/health.py +67 -0
- apifacts/api/match.py +194 -0
- apifacts/api/models.py +149 -0
- apifacts/api/normalize.py +249 -0
- apifacts/api/ratelimit.py +67 -0
- apifacts/api/schema.sql +42 -0
- apifacts/api/seed_hosts.py +323 -0
- apifacts/api/store.py +110 -0
- apifacts/bot/__init__.py +1 -0
- apifacts/bot/cli.py +73 -0
- apifacts/bot/collect.py +146 -0
- apifacts/bot/pr.py +327 -0
- apifacts/bot/token.py +96 -0
- apifacts/cli.py +96 -0
- apifacts/cli_check.py +94 -0
- apifacts/client/__init__.py +6 -0
- apifacts/client/offline.py +142 -0
- apifacts/client/render.py +117 -0
- apifacts/client/request.py +251 -0
- apifacts/client/settings.py +26 -0
- apifacts/client/snapshot/README.md +6 -0
- apifacts/client/snapshot/facts/github/anonymous-cache-ignores-api-version.yaml +29 -0
- apifacts/client/snapshot/facts/github/anonymous-core-rate-limit.yaml +27 -0
- apifacts/client/snapshot/facts/github/anonymous-repo-has-no-permissions.yaml +26 -0
- apifacts/client/snapshot/facts/github/anonymous-search-rate-limit.yaml +27 -0
- apifacts/client/snapshot/facts/github/api-version-2026-drops-has-downloads.yaml +27 -0
- apifacts/client/snapshot/facts/github/default-api-version-end-of-support.yaml +22 -0
- apifacts/client/snapshot/facts/github/error-status-is-a-string.yaml +28 -0
- apifacts/client/snapshot/facts/github/graphql-anonymous-limit-zero.yaml +27 -0
- apifacts/client/snapshot/facts/github/missing-repo-404.yaml +30 -0
- apifacts/client/snapshot/facts/github/pagination-in-link-header.yaml +27 -0
- apifacts/client/snapshot/facts/github/per-page-silently-capped-at-100.yaml +27 -0
- apifacts/client/snapshot/facts/github/private-repo-404-not-403.yaml +26 -0
- apifacts/client/snapshot/facts/github/rate-object-closing-down.yaml +22 -0
- apifacts/client/snapshot/facts/github/renamed-repo-301.yaml +31 -0
- apifacts/client/snapshot/facts/github/repository-object-fingerprint.yaml +26 -0
- apifacts/client/snapshot/facts/github/search-first-1000-results.yaml +31 -0
- apifacts/client/snapshot/facts/github/search-query-longer-than-256.yaml +31 -0
- apifacts/client/snapshot/facts/github/secondary-rate-limit-429.yaml +25 -0
- apifacts/client/snapshot/facts/github/secondary-rate-limit.yaml +25 -0
- apifacts/client/snapshot/facts/github/trailing-slash-404.yaml +31 -0
- apifacts/client/snapshot/facts/github/unsupported-api-version-400.yaml +31 -0
- apifacts/client/snapshot/facts/github/unsupported-method-404.yaml +24 -0
- apifacts/client/snapshot/facts/github/user-agent-required.yaml +24 -0
- apifacts/client/snapshot/facts/google-calendar/calendar-get-rejects-api-keys.yaml +31 -0
- apifacts/client/snapshot/facts/google-calendar/calendar-not-found-404.yaml +30 -0
- apifacts/client/snapshot/facts/google-calendar/events-page-default-250.yaml +22 -0
- apifacts/client/snapshot/facts/google-calendar/invalid-page-token-400.yaml +30 -0
- apifacts/client/snapshot/facts/google-calendar/max-results-above-2500-accepted.yaml +29 -0
- apifacts/client/snapshot/facts/google-calendar/max-results-zero-refused.yaml +30 -0
- apifacts/client/snapshot/facts/google-calendar/missing-api-key-403.yaml +30 -0
- apifacts/client/snapshot/facts/google-calendar/order-by-start-time-needs-single-events.yaml +30 -0
- apifacts/client/snapshot/facts/google-calendar/public-free-busy-calendar-via-api-key.yaml +28 -0
- apifacts/client/snapshot/facts/google-calendar/rate-limit-403-or-429.yaml +24 -0
- apifacts/client/snapshot/facts/google-calendar/sync-token-with-time-min-400.yaml +30 -0
- apifacts/client/snapshot/facts/google-calendar/time-min-filters-on-end-time.yaml +22 -0
- apifacts/client/snapshot/facts/google-calendar/time-min-without-offset-bad-request.yaml +31 -0
- apifacts/client/snapshot/facts/google-calendar/unknown-sync-token-410.yaml +30 -0
- apifacts/client/snapshot/facts/google-firestore/array-and-map-values-are-wrapped.yaml +28 -0
- apifacts/client/snapshot/facts/google-firestore/document-envelope-keys.yaml +28 -0
- apifacts/client/snapshot/facts/google-firestore/document-not-found-404.yaml +31 -0
- apifacts/client/snapshot/facts/google-firestore/document-size-limit-1mib.yaml +22 -0
- apifacts/client/snapshot/facts/google-firestore/empty-collection-returns-empty-object.yaml +28 -0
- apifacts/client/snapshot/facts/google-firestore/free-quota-resets-midnight-pacific.yaml +22 -0
- apifacts/client/snapshot/facts/google-firestore/integer-value-is-a-string.yaml +29 -0
- apifacts/client/snapshot/facts/google-firestore/invalid-field-path-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-firestore/invalid-page-token-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-firestore/negative-page-size-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-firestore/null-value-is-json-null.yaml +28 -0
- apifacts/client/snapshot/facts/google-firestore/order-by-unknown-field-accepted.yaml +28 -0
- apifacts/client/snapshot/facts/google-firestore/server-access-bypasses-security-rules.yaml +22 -0
- apifacts/client/snapshot/facts/google-firestore/subcollection-of-missing-document-is-empty.yaml +28 -0
- apifacts/client/snapshot/facts/google-firestore/timestamp-value-rfc3339-utc.yaml +28 -0
- apifacts/client/snapshot/facts/google-firestore/unauthenticated-read-under-test-mode.yaml +31 -0
- apifacts/client/snapshot/facts/google-firestore/unknown-database-404.yaml +31 -0
- apifacts/client/snapshot/facts/google-firestore/unknown-project-403.yaml +31 -0
- apifacts/client/snapshot/facts/google-gemini/daily-quota-resets-midnight-pacific.yaml +22 -0
- apifacts/client/snapshot/facts/google-gemini/errors-use-google-rpc-envelope.yaml +28 -0
- apifacts/client/snapshot/facts/google-gemini/gemini-1-5-models-retired.yaml +31 -0
- apifacts/client/snapshot/facts/google-gemini/gemini-2-0-flash-served-past-shutdown-date.yaml +29 -0
- apifacts/client/snapshot/facts/google-gemini/invalid-api-key-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-gemini/invalid-page-token-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-gemini/latest-aliases-v1beta-only.yaml +31 -0
- apifacts/client/snapshot/facts/google-gemini/leaked-key-403.yaml +27 -0
- apifacts/client/snapshot/facts/google-gemini/missing-api-key-403.yaml +31 -0
- apifacts/client/snapshot/facts/google-gemini/model-not-found-404.yaml +31 -0
- apifacts/client/snapshot/facts/google-gemini/model-object-fingerprint.yaml +28 -0
- apifacts/client/snapshot/facts/google-gemini/models-list-default-page-50.yaml +28 -0
- apifacts/client/snapshot/facts/google-gemini/page-size-negative-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-gemini/page-size-zero-means-default.yaml +28 -0
- apifacts/client/snapshot/facts/google-gemini/rate-limits-per-project.yaml +22 -0
- apifacts/client/snapshot/facts/google-gemini/resource-exhausted-429.yaml +25 -0
- apifacts/client/snapshot/facts/google-gemini/unknown-query-parameter-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-sheets/batch-get-fails-whole.yaml +31 -0
- apifacts/client/snapshot/facts/google-sheets/columns-major-inner-empty-cells.yaml +28 -0
- apifacts/client/snapshot/facts/google-sheets/date-time-formatted-string.yaml +28 -0
- apifacts/client/snapshot/facts/google-sheets/default-sheet-name-follows-locale.yaml +31 -0
- apifacts/client/snapshot/facts/google-sheets/empty-range-omits-values.yaml +28 -0
- apifacts/client/snapshot/facts/google-sheets/formatted-values-follow-locale.yaml +28 -0
- apifacts/client/snapshot/facts/google-sheets/formulas-in-locale-syntax.yaml +28 -0
- apifacts/client/snapshot/facts/google-sheets/invalid-api-key-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-sheets/invalid-fields-mask-generic-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-sheets/invalid-value-render-option-400.yaml +31 -0
- apifacts/client/snapshot/facts/google-sheets/missing-api-key-403.yaml +31 -0
- apifacts/client/snapshot/facts/google-sheets/new-sheet-grid-1000x26.yaml +28 -0
- apifacts/client/snapshot/facts/google-sheets/quota-per-project-and-user.yaml +24 -0
- apifacts/client/snapshot/facts/google-sheets/range-clipped-to-grid.yaml +28 -0
- apifacts/client/snapshot/facts/google-sheets/request-timeout-180s.yaml +22 -0
- apifacts/client/snapshot/facts/google-sheets/spreadsheet-not-found-404.yaml +31 -0
- apifacts/client/snapshot/facts/google-sheets/unformatted-dates-are-serial-numbers.yaml +28 -0
- apifacts/client/snapshot/facts/google-sheets/unknown-sheet-unable-to-parse.yaml +31 -0
- apifacts/client/snapshot/facts/google-sheets/value-rows-not-padded.yaml +28 -0
- apifacts/client/snapshot/facts/google-sheets/whole-column-range-expanded.yaml +28 -0
- apifacts/client/snapshot/facts/pypi/downloads-always-minus-one.yaml +27 -0
- apifacts/client/snapshot/facts/pypi/json-api-accepts-unnormalized-name.yaml +26 -0
- apifacts/client/snapshot/facts/pypi/json-api-cdn-cache.yaml +27 -0
- apifacts/client/snapshot/facts/pypi/json-trailing-slash-301.yaml +28 -0
- apifacts/client/snapshot/facts/pypi/no-edge-rate-limit.yaml +22 -0
- apifacts/client/snapshot/facts/pypi/project-json-fingerprint.yaml +26 -0
- apifacts/client/snapshot/facts/pypi/project-not-found-404.yaml +30 -0
- apifacts/client/snapshot/facts/pypi/release-json-has-no-releases-key.yaml +27 -0
- apifacts/client/snapshot/facts/pypi/release-metadata-from-first-upload.yaml +22 -0
- apifacts/client/snapshot/facts/pypi/release-not-found-404.yaml +30 -0
- apifacts/client/snapshot/facts/pypi/releases-key-deprecated.yaml +22 -0
- apifacts/client/snapshot/facts/pypi/simple-api-html-unless-accept-json.yaml +27 -0
- apifacts/client/snapshot/facts/pypi/simple-not-found-404.yaml +30 -0
- apifacts/client/snapshot/facts/pypi/simple-unnormalized-name-301.yaml +29 -0
- apifacts/client/snapshot/facts/pypi/simple-without-trailing-slash-301.yaml +28 -0
- apifacts/client/snapshot/facts/pypi/xml-rpc-planned-deprecation.yaml +22 -0
- apifacts/client/snapshot/facts/salesforce/developer-edition-15000-daily-requests.yaml +28 -0
- apifacts/client/snapshot/facts/salesforce/empty-query-result-shape.yaml +28 -0
- apifacts/client/snapshot/facts/salesforce/errors-are-a-json-array.yaml +28 -0
- apifacts/client/snapshot/facts/salesforce/missing-session-401.yaml +31 -0
- apifacts/client/snapshot/facts/salesforce/non-id-path-read-as-external-id.yaml +31 -0
- apifacts/client/snapshot/facts/salesforce/record-not-found-404.yaml +31 -0
- apifacts/client/snapshot/facts/salesforce/rest-on-login-host-url-not-reset.yaml +31 -0
- apifacts/client/snapshot/facts/salesforce/retired-api-version-410.yaml +30 -0
- apifacts/client/snapshot/facts/salesforce/sforce-limit-info-header.yaml +28 -0
- apifacts/client/snapshot/facts/salesforce/soap-login-needs-use-any-api-auth.yaml +24 -0
- apifacts/client/snapshot/facts/salesforce/soap-login-rate-3600-per-hour.yaml +22 -0
- apifacts/client/snapshot/facts/salesforce/soap-login-retiring-summer-27.yaml +22 -0
- apifacts/client/snapshot/facts/salesforce/soql-fields-all-needs-limit.yaml +31 -0
- apifacts/client/snapshot/facts/salesforce/soql-malformed-query-400.yaml +31 -0
- apifacts/client/snapshot/facts/salesforce/soql-unknown-field-invalid-field.yaml +31 -0
- apifacts/client/snapshot/facts/salesforce/soql-unknown-object-invalid-type.yaml +31 -0
- apifacts/client/snapshot/facts/salesforce/unknown-sobject-describe-404.yaml +31 -0
- apifacts/client/snapshot/facts/salesforce/version-list-needs-no-session.yaml +28 -0
- apifacts/client/snapshot/facts/solana/get-block-commitment-processed.yaml +31 -0
- apifacts/client/snapshot/facts/solana/get-confirmed-block-removed.yaml +32 -0
- apifacts/client/snapshot/facts/solana/get-confirmed-transaction-removed.yaml +32 -0
- apifacts/client/snapshot/facts/solana/get-program-accounts-blocked-403.yaml +31 -0
- apifacts/client/snapshot/facts/solana/get-recent-blockhash-removed.yaml +32 -0
- apifacts/client/snapshot/facts/solana/invalid-pubkey-wrong-size.yaml +31 -0
- apifacts/client/snapshot/facts/solana/jsonrpc-errors-use-http-200.yaml +28 -0
- apifacts/client/snapshot/facts/solana/multiple-accounts-max-100.yaml +31 -0
- apifacts/client/snapshot/facts/solana/per-method-rate-limit-headers.yaml +26 -0
- apifacts/client/snapshot/facts/solana/public-endpoint-403-blocked.yaml +24 -0
- apifacts/client/snapshot/facts/solana/public-endpoint-not-for-production.yaml +22 -0
- apifacts/client/snapshot/facts/solana/public-mainnet-rate-limits.yaml +24 -0
- apifacts/client/snapshot/facts/solana/rent-exemption-minimum-zero-bytes.yaml +26 -0
- apifacts/client/snapshot/facts/solana/signatures-for-address-limit-1000.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/amount-too-small.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/card-declined-generic.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/card-expired.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/card-incorrect-cvc.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/card-insufficient-funds.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/card-lost-generic-message.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/customer-not-found.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/deleted-customer-returns-200.yaml +28 -0
- apifacts/client/snapshot/facts/stripe/execution-errors-are-replayed.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/expand-not-expandable.yaml +30 -0
- apifacts/client/snapshot/facts/stripe/idempotency-key-other-endpoint.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/idempotency-key-reuse-different-params.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/idempotency-key-too-long.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/idempotency-keys-pruned-after-24h.yaml +22 -0
- apifacts/client/snapshot/facts/stripe/idempotent-replay-header.yaml +28 -0
- apifacts/client/snapshot/facts/stripe/invalid-api-version-400.yaml +30 -0
- apifacts/client/snapshot/facts/stripe/limit-not-an-integer.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/limit-zero-returns-one.yaml +28 -0
- apifacts/client/snapshot/facts/stripe/missing-api-key-401.yaml +30 -0
- apifacts/client/snapshot/facts/stripe/parameter-unknown.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/payment-method-types-removed.yaml +30 -0
- apifacts/client/snapshot/facts/stripe/rate-limited-reason-header.yaml +24 -0
- apifacts/client/snapshot/facts/stripe/sandbox-rate-limit-lower.yaml +22 -0
- apifacts/client/snapshot/facts/stripe/server-assigned-idempotency-key.yaml +28 -0
- apifacts/client/snapshot/facts/stripe/starting-after-missing-object.yaml +31 -0
- apifacts/client/snapshot/facts/stripe/validation-errors-are-not-replayed.yaml +31 -0
- apifacts/client/snapshot/facts/telegram/chat-id-empty-400.yaml +30 -0
- apifacts/client/snapshot/facts/telegram/chat-not-found-400.yaml +30 -0
- apifacts/client/snapshot/facts/telegram/error-envelope-ok-false.yaml +29 -0
- apifacts/client/snapshot/facts/telegram/flood-control-429-retry-after.yaml +24 -0
- apifacts/client/snapshot/facts/telegram/get-updates-blocked-by-webhook.yaml +22 -0
- apifacts/client/snapshot/facts/telegram/get-updates-limit-not-validated.yaml +29 -0
- apifacts/client/snapshot/facts/telegram/group-migrated-to-supergroup.yaml +22 -0
- apifacts/client/snapshot/facts/telegram/invalid-file-id-400.yaml +30 -0
- apifacts/client/snapshot/facts/telegram/invalid-token-401.yaml +30 -0
- apifacts/client/snapshot/facts/telegram/malformed-token-404.yaml +30 -0
- apifacts/client/snapshot/facts/telegram/message-rate-limits.yaml +24 -0
- apifacts/client/snapshot/facts/telegram/method-names-case-insensitive.yaml +29 -0
- apifacts/client/snapshot/facts/telegram/no-webhook-url-empty-string.yaml +28 -0
- apifacts/client/snapshot/facts/telegram/unknown-method-404.yaml +30 -0
- apifacts/client/snapshot/facts/telegram/updates-kept-24-hours.yaml +22 -0
- apifacts/client/snapshot/facts/telegram/user-not-found-400.yaml +30 -0
- apifacts/client/snapshot/services/github.yaml +44 -0
- apifacts/client/snapshot/services/google-calendar.yaml +34 -0
- apifacts/client/snapshot/services/google-firestore.yaml +39 -0
- apifacts/client/snapshot/services/google-gemini.yaml +35 -0
- apifacts/client/snapshot/services/google-sheets.yaml +33 -0
- apifacts/client/snapshot/services/pypi.yaml +34 -0
- apifacts/client/snapshot/services/salesforce.yaml +38 -0
- apifacts/client/snapshot/services/solana.yaml +44 -0
- apifacts/client/snapshot/services/stripe.yaml +41 -0
- apifacts/client/snapshot/services/telegram.yaml +39 -0
- apifacts/client/transport.py +235 -0
- apifacts/fixes/__init__.py +1 -0
- apifacts/fixes/templates.py +84 -0
- apifacts/pages/__init__.py +5 -0
- apifacts/pages/build.py +215 -0
- apifacts/pages/cli.py +74 -0
- apifacts/pages/errors_index.py +42 -0
- apifacts/pages/templates.py +364 -0
- apifacts/probe/__init__.py +12 -0
- apifacts/probe/assertions.py +170 -0
- apifacts/probe/budget.py +131 -0
- apifacts/probe/cli.py +145 -0
- apifacts/probe/evidence.py +280 -0
- apifacts/probe/google_sa.py +148 -0
- apifacts/probe/http.py +281 -0
- apifacts/probe/keys.py +43 -0
- apifacts/probe/ledger.py +90 -0
- apifacts/probe/loader.py +93 -0
- apifacts/probe/oauth_refresh.py +127 -0
- apifacts/probe/redact.py +123 -0
- apifacts/probe/results.py +147 -0
- apifacts/probe/runner.py +700 -0
- apifacts/probe/templating.py +138 -0
- apifacts/quorum/__init__.py +2 -0
- apifacts/quorum/cli.py +87 -0
- apifacts/quorum/draft.py +163 -0
- apifacts/quorum/group.py +123 -0
- apifacts/quorum/rules.py +82 -0
- apifacts/quorum/run.py +132 -0
- apifacts/schema/__init__.py +39 -0
- apifacts/schema/error_paths.py +44 -0
- apifacts/schema/json/fact.schema.json +182 -0
- apifacts/schema/json/fix.schema.json +244 -0
- apifacts/schema/json/observation.schema.json +25 -0
- apifacts/schema/json/probe.schema.json +181 -0
- apifacts/schema/json/service.schema.json +215 -0
- apifacts/schema/models.py +702 -0
- apifacts/schema/registry.py +238 -0
- apifacts/schema/substance.py +24 -0
- apifacts/schema/validate.py +433 -0
- apifacts/schema/yamlio.py +70 -0
- apifacts-0.1.0.dist-info/METADATA +94 -0
- apifacts-0.1.0.dist-info/RECORD +264 -0
- apifacts-0.1.0.dist-info/WHEEL +4 -0
- apifacts-0.1.0.dist-info/entry_points.txt +2 -0
- apifacts-0.1.0.dist-info/licenses/LICENSE +21 -0
- apifacts-0.1.0.dist-info/licenses/LICENSE-DATA +9 -0
apifacts/__init__.py
ADDED
apifacts/api/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""`check()` service: POST /v0/check (request = contribution), read endpoints, ingestion.
|
|
2
|
+
|
|
3
|
+
Spec: docs/specs/03-check-contract.md. Nothing here calls a model API or the network; the answer
|
|
4
|
+
comes from the facts in git, the observation is a normalized row in sqlite or nothing.
|
|
5
|
+
"""
|
apifacts/api/app.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""FastAPI application: `uvicorn apifacts.api.app:app` (spec 03).
|
|
2
|
+
|
|
3
|
+
Configuration (environment, read at startup): `APIFACTS_REPO` (registry checkout, default `.`),
|
|
4
|
+
`APIFACTS_DB` (sqlite file, default `observations.sqlite`), `APIFACTS_TRUSTED_PROXIES`
|
|
5
|
+
(comma-separated IPs/CIDRs whose `X-Forwarded-For` is believed, default `127.0.0.1,::1`),
|
|
6
|
+
`APIFACTS_REGISTRY_VERSION` (overrides the version derived from the facts), `APIFACTS_RESULTS`
|
|
7
|
+
(the probe runner's results.jsonl for `/healthz`, default `<repo>/var/results.jsonl`).
|
|
8
|
+
|
|
9
|
+
The facts index is read once, at startup: a new registry needs a restart (`ops/deploy.sh` does it).
|
|
10
|
+
|
|
11
|
+
One process: rate limits live in memory and the store is one sqlite connection.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from contextlib import asynccontextmanager
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from datetime import UTC, datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
26
|
+
from fastapi.exceptions import RequestValidationError
|
|
27
|
+
from fastapi.responses import JSONResponse, Response
|
|
28
|
+
from starlette.datastructures import Headers
|
|
29
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
30
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
31
|
+
|
|
32
|
+
from apifacts.api.facts_index import (
|
|
33
|
+
FactsIndex,
|
|
34
|
+
IndexedFact,
|
|
35
|
+
ServiceEntry,
|
|
36
|
+
Template,
|
|
37
|
+
instance_hosts_of,
|
|
38
|
+
)
|
|
39
|
+
from apifacts.api.health import last_probe_run
|
|
40
|
+
from apifacts.api.match import (
|
|
41
|
+
HostTarget,
|
|
42
|
+
Locator,
|
|
43
|
+
Network,
|
|
44
|
+
Target,
|
|
45
|
+
UrlTarget,
|
|
46
|
+
best_template,
|
|
47
|
+
classify_ua,
|
|
48
|
+
client_ip,
|
|
49
|
+
network_of,
|
|
50
|
+
parse_proxies,
|
|
51
|
+
parse_target,
|
|
52
|
+
)
|
|
53
|
+
from apifacts.api.models import (
|
|
54
|
+
CheckRequest,
|
|
55
|
+
CheckResponse,
|
|
56
|
+
ErrorResponse,
|
|
57
|
+
FactOut,
|
|
58
|
+
FactsResponse,
|
|
59
|
+
Health,
|
|
60
|
+
ServicesResponse,
|
|
61
|
+
)
|
|
62
|
+
from apifacts.api.normalize import ExtractedSignal, extract_signal, find_urls, looks_like_prompt
|
|
63
|
+
from apifacts.api.ratelimit import RateLimiter
|
|
64
|
+
from apifacts.api.seed_hosts import metric_host
|
|
65
|
+
from apifacts.api.store import Store, salt_period
|
|
66
|
+
from apifacts.schema.registry import on_vendor_domain
|
|
67
|
+
|
|
68
|
+
LICENSE = "CC-BY-4.0"
|
|
69
|
+
MAX_BODY = 8192 # spec 03: max request body
|
|
70
|
+
DAILY_CAP = 1000 # spec 05: observations per reporter net per day
|
|
71
|
+
CACHE_CONTROL = "public, max-age=300"
|
|
72
|
+
DEFAULT_TRUSTED_PROXIES = ("127.0.0.1", "::1")
|
|
73
|
+
CHECK_PATH = "/v0/check"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _error_body(code: str, message: str, field: str | None = None) -> dict[str, Any]:
|
|
77
|
+
return {"error": {"code": code, "message": message, "field": field}}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _error(status: int, code: str, message: str, field: str | None = None, **kw) -> JSONResponse:
|
|
81
|
+
return JSONResponse(_error_body(code, message, field), status_code=status, **kw)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fail(status: int, code: str, message: str, field: str | None = None) -> HTTPException:
|
|
85
|
+
return HTTPException(status, detail=_error_body(code, message, field)["error"])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── state ───────────────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class _State:
|
|
93
|
+
index: FactsIndex
|
|
94
|
+
store: Store
|
|
95
|
+
daily_cap: int
|
|
96
|
+
trusted_proxies: tuple[Network, ...]
|
|
97
|
+
utcnow: Callable[[], datetime]
|
|
98
|
+
results: Path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _state(request: Request) -> _State:
|
|
102
|
+
return request.app.state.apifacts
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _caller_ip(scope_client: Any, headers: Headers, trusted: tuple[Network, ...]) -> str:
|
|
106
|
+
peer = scope_client[0] if scope_client else ""
|
|
107
|
+
return client_ip(peer, headers.get("x-forwarded-for"), trusted)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _CheckGuard:
|
|
111
|
+
"""Before FastAPI reads the body of POST /v0/check: rate limit (429), then size (413), so
|
|
112
|
+
neither a flood nor an oversized body costs a parse."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, app: ASGIApp, limiter: RateLimiter, trusted: tuple[Network, ...]):
|
|
115
|
+
self.app, self.limiter, self.trusted = app, limiter, trusted
|
|
116
|
+
self._key = secrets.token_bytes(16) # limiter keys: never a raw network, even in memory
|
|
117
|
+
|
|
118
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
119
|
+
if scope["type"] != "http" or scope["path"] != CHECK_PATH or scope["method"] != "POST":
|
|
120
|
+
await self.app(scope, receive, send)
|
|
121
|
+
return
|
|
122
|
+
headers = Headers(scope=scope)
|
|
123
|
+
net = network_of(_caller_ip(scope.get("client"), headers, self.trusted))
|
|
124
|
+
key = hashlib.blake2b(net.encode(), key=self._key, digest_size=16).hexdigest()
|
|
125
|
+
if (retry := self.limiter.hit(key)) is not None:
|
|
126
|
+
response = _error(
|
|
127
|
+
429, "rate_limited", "too many requests", headers={"Retry-After": str(retry)}
|
|
128
|
+
)
|
|
129
|
+
await response(scope, receive, send)
|
|
130
|
+
return
|
|
131
|
+
length = headers.get("content-length")
|
|
132
|
+
if length is not None and not length.isdigit():
|
|
133
|
+
await _error(400, "invalid_request", "malformed Content-Length")(scope, receive, send)
|
|
134
|
+
return
|
|
135
|
+
if length is not None and int(length) > MAX_BODY:
|
|
136
|
+
await _error(413, "body_too_large", f"body over {MAX_BODY} bytes")(scope, receive, send)
|
|
137
|
+
return
|
|
138
|
+
received = 0
|
|
139
|
+
|
|
140
|
+
async def limited() -> Message:
|
|
141
|
+
nonlocal received
|
|
142
|
+
message = await receive()
|
|
143
|
+
if message["type"] == "http.request":
|
|
144
|
+
received += len(message.get("body", b""))
|
|
145
|
+
if received > MAX_BODY: # chunked body: FastAPI re-raises HTTPException as is
|
|
146
|
+
raise _fail(413, "body_too_large", f"body over {MAX_BODY} bytes")
|
|
147
|
+
return message
|
|
148
|
+
|
|
149
|
+
await self.app(scope, limited, send)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── POST /v0/check ──────────────────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _resolve(
|
|
156
|
+
index: FactsIndex, target: Target | None, method: str | None
|
|
157
|
+
) -> tuple[ServiceEntry | None, Template | None]:
|
|
158
|
+
"""Spec 03 step 1: host → services (exact `hosts`, or `auth.instance_hosts` and its
|
|
159
|
+
subdomains, D-090), path → template (`{id}` = one segment)."""
|
|
160
|
+
if isinstance(target, UrlTarget):
|
|
161
|
+
candidates = index.services_for_host(target.host)
|
|
162
|
+
for entry in candidates:
|
|
163
|
+
if (template := best_template(entry.info.templates, method, target.path)) is not None:
|
|
164
|
+
return entry, template
|
|
165
|
+
return (candidates[0] if len(candidates) == 1 else None), None
|
|
166
|
+
if isinstance(target, HostTarget):
|
|
167
|
+
candidates = index.services_for_host(target.host)
|
|
168
|
+
elif isinstance(target, Locator):
|
|
169
|
+
candidates = index.services_for_package(target.ecosystem, target.package)
|
|
170
|
+
else:
|
|
171
|
+
candidates = []
|
|
172
|
+
return (candidates[0] if len(candidates) == 1 else None), None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _drop_reason(entry: ServiceEntry, signal: ExtractedSignal | None) -> str | None:
|
|
176
|
+
"""Spec 05: a string with a URL off the vendor's hosts, or shaped like a prompt."""
|
|
177
|
+
raw = signal.raw_string if signal else None
|
|
178
|
+
if not raw:
|
|
179
|
+
return None
|
|
180
|
+
if any(not on_vendor_domain(url, entry.info.vendor_hosts) for url in find_urls(raw)):
|
|
181
|
+
return "dropped_foreign_url"
|
|
182
|
+
if looks_like_prompt(raw):
|
|
183
|
+
return "dropped_prompt"
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _ingest(
|
|
188
|
+
state: _State,
|
|
189
|
+
entry: ServiceEntry,
|
|
190
|
+
template: Template,
|
|
191
|
+
signal: ExtractedSignal,
|
|
192
|
+
req: CheckRequest,
|
|
193
|
+
target: UrlTarget,
|
|
194
|
+
ip: str,
|
|
195
|
+
user_agent: str | None,
|
|
196
|
+
now: datetime,
|
|
197
|
+
) -> bool:
|
|
198
|
+
"""Spec 03 step 4: one normalized row, or nothing (dropped and counted). The reporter salt
|
|
199
|
+
is the week's (Q6): one network keeps one id for the whole quorum window."""
|
|
200
|
+
day = now.date().isoformat()
|
|
201
|
+
if (dropped := _drop_reason(entry, signal)) is not None:
|
|
202
|
+
state.store.bump(dropped, day)
|
|
203
|
+
return False
|
|
204
|
+
salt = state.store.period_salt(salt_period(now.date()).isoformat())
|
|
205
|
+
context = req.context
|
|
206
|
+
sdk = context.sdk if context else None
|
|
207
|
+
row = {
|
|
208
|
+
"received_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
209
|
+
"service": entry.key,
|
|
210
|
+
"method": template[0],
|
|
211
|
+
"path_template": template[1],
|
|
212
|
+
"status": signal.status,
|
|
213
|
+
"error_hash": signal.error_hash,
|
|
214
|
+
"normalized_error": signal.normalized,
|
|
215
|
+
"reporter_net": hashlib.sha256(salt + network_of(ip).encode()).hexdigest(),
|
|
216
|
+
"reporter_ua_class": classify_ua(user_agent),
|
|
217
|
+
"sdk_package": sdk.package if sdk else None,
|
|
218
|
+
"sdk_version": sdk.version if sdk else None,
|
|
219
|
+
"env_guess": (context.env_hint if context else None) or entry.env_for_host(target.host),
|
|
220
|
+
"vantage_country": None,
|
|
221
|
+
}
|
|
222
|
+
if state.store.add_observation(row, daily_cap=state.daily_cap) is None:
|
|
223
|
+
state.store.bump("dropped_daily_cap", day)
|
|
224
|
+
return False
|
|
225
|
+
state.store.bump("stored", day)
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _count_unregistered(store: Store, target: Target | None, day: str) -> None:
|
|
230
|
+
"""Spec 03 step 1 (Q7): one anonymous daily count, labelled with the host only when it is a
|
|
231
|
+
seed candidate's API host; every other target (package, unparsed, unknown host) is `other`.
|
|
232
|
+
The unlabelled total is kept as well, so a reader can sum one name."""
|
|
233
|
+
host = target.host if isinstance(target, UrlTarget | HostTarget) else None
|
|
234
|
+
store.bump("unregistered_target", day)
|
|
235
|
+
store.bump(f"unregistered_target:{metric_host(host)}", day)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _observed(entry: ServiceEntry | None) -> dict[str, Any]:
|
|
239
|
+
"""What the probes know about the target. Probe results are not wired in yet (W2/W4):
|
|
240
|
+
the structure is always there, with an empty distribution and nulls."""
|
|
241
|
+
return {
|
|
242
|
+
"status_distribution_30d": {},
|
|
243
|
+
"p50_latency_ms": None,
|
|
244
|
+
"last_probe": None,
|
|
245
|
+
"rate_limit_headers": list(entry.rate_limit_headers) if entry else [],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _registry(index: FactsIndex, entry: ServiceEntry | None) -> dict[str, Any]:
|
|
250
|
+
return {
|
|
251
|
+
"version": index.version,
|
|
252
|
+
"facts_for_service": len(entry.facts) if entry else 0,
|
|
253
|
+
"license": LICENSE,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _check(state: _State, req: CheckRequest, ip: str, user_agent: str | None) -> dict[str, Any]:
|
|
258
|
+
index, now = state.index, state.utcnow()
|
|
259
|
+
target = parse_target(req.target.url)
|
|
260
|
+
method = req.target.method
|
|
261
|
+
if isinstance(target, UrlTarget) and method is None:
|
|
262
|
+
raise _fail(
|
|
263
|
+
400, "invalid_request", "target.method is required for a URL with a path",
|
|
264
|
+
"target.method",
|
|
265
|
+
)
|
|
266
|
+
sdk_ctx = req.context.sdk if req.context else None
|
|
267
|
+
sdk = Locator(sdk_ctx.ecosystem, sdk_ctx.package, sdk_ctx.version) if sdk_ctx else None
|
|
268
|
+
entry, template = _resolve(index, target, method)
|
|
269
|
+
|
|
270
|
+
facts: list[IndexedFact]
|
|
271
|
+
stored = False
|
|
272
|
+
if isinstance(target, UrlTarget) and entry is not None and template is not None:
|
|
273
|
+
signal = None
|
|
274
|
+
if req.error is not None:
|
|
275
|
+
signal = extract_signal(req.error.status, req.error.body_excerpt, entry.signal_fields)
|
|
276
|
+
if signal is None:
|
|
277
|
+
facts = index.for_template(entry, template, sdk)
|
|
278
|
+
reason = None if facts else "no_fact_for_signal"
|
|
279
|
+
elif signal.empty:
|
|
280
|
+
facts, reason = index.service_level(entry, sdk), "signal_not_extractable"
|
|
281
|
+
else:
|
|
282
|
+
facts, answered = index.for_signal(entry, template, signal, sdk)
|
|
283
|
+
reason = None if answered else "no_fact_for_signal"
|
|
284
|
+
if signal is not None and not signal.empty: # pre-flight and empty errors: never (Q5)
|
|
285
|
+
stored = _ingest(state, entry, template, signal, req, target, ip, user_agent, now)
|
|
286
|
+
elif entry is not None and isinstance(target, HostTarget):
|
|
287
|
+
facts = index.for_service(entry, sdk)
|
|
288
|
+
reason = None if facts else "no_fact_for_signal"
|
|
289
|
+
elif entry is not None and isinstance(target, Locator):
|
|
290
|
+
facts = index.for_package(entry, target)
|
|
291
|
+
reason = None if facts else "no_fact_for_signal"
|
|
292
|
+
else: # default-deny: answer with what the host's service knows, store nothing
|
|
293
|
+
facts = index.service_level(entry, sdk) if entry is not None else []
|
|
294
|
+
entry = entry if isinstance(target, UrlTarget) else None
|
|
295
|
+
reason = "unregistered_target"
|
|
296
|
+
_count_unregistered(state.store, target, now.date().isoformat())
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"service": entry.key if entry else None,
|
|
300
|
+
"matched_template": {"method": template[0], "path": template[1]} if template else None,
|
|
301
|
+
"facts": [f.document for f in facts],
|
|
302
|
+
"observed": _observed(entry),
|
|
303
|
+
"unknown": {"reason": reason, "stored_as_observation": stored},
|
|
304
|
+
"registry": _registry(index, entry),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ── read endpoints ──────────────────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _cached(request: Request, payload: Any) -> Response:
|
|
312
|
+
"""JSON with a strong ETag and `Cache-Control: public, max-age=300` (spec 03)."""
|
|
313
|
+
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
314
|
+
etag = f'"{hashlib.sha256(body).hexdigest()[:32]}"'
|
|
315
|
+
headers = {"ETag": etag, "Cache-Control": CACHE_CONTROL}
|
|
316
|
+
wanted = request.headers.get("if-none-match", "")
|
|
317
|
+
tags = {t.strip().removeprefix("W/") for t in wanted.split(",")}
|
|
318
|
+
if etag in tags or "*" in tags:
|
|
319
|
+
return Response(status_code=304, headers=headers)
|
|
320
|
+
return Response(body, media_type="application/json", headers=headers)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ── errors ──────────────────────────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _field(loc: tuple[Any, ...]) -> str | None:
|
|
327
|
+
parts = [str(p) for p in loc[1:]] if loc and loc[0] == "body" else [str(p) for p in loc]
|
|
328
|
+
return ".".join(parts) or None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def _on_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
332
|
+
errors = exc.errors()
|
|
333
|
+
extra = next((e for e in errors if e.get("type") == "extra_forbidden"), None)
|
|
334
|
+
if extra is not None:
|
|
335
|
+
field = _field(tuple(extra["loc"]))
|
|
336
|
+
return _error(400, "unknown_field", f"unknown field {field}", field)
|
|
337
|
+
first = errors[0] if errors else {}
|
|
338
|
+
message = first.get("msg", "invalid request") # pydantic messages never echo the input
|
|
339
|
+
return _error(400, "invalid_request", message, _field(tuple(first.get("loc", ()))))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
_DEFAULT_CODES = {404: "not_found", 405: "method_not_allowed", 413: "body_too_large"}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def _on_http(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
346
|
+
if isinstance(exc.detail, dict) and "code" in exc.detail:
|
|
347
|
+
body = {"error": exc.detail}
|
|
348
|
+
else:
|
|
349
|
+
code = _DEFAULT_CODES.get(exc.status_code, "error")
|
|
350
|
+
body = _error_body(code, str(exc.detail))
|
|
351
|
+
return JSONResponse(body, status_code=exc.status_code, headers=exc.headers)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _without_422(default: Callable[[], dict[str, Any]]) -> Callable[[], dict[str, Any]]:
|
|
355
|
+
"""Validation errors are answered 400 with the error envelope, never FastAPI's 422."""
|
|
356
|
+
|
|
357
|
+
def openapi() -> dict[str, Any]:
|
|
358
|
+
schema = default()
|
|
359
|
+
for operations in schema.get("paths", {}).values():
|
|
360
|
+
for operation in operations.values():
|
|
361
|
+
operation.get("responses", {}).pop("422", None)
|
|
362
|
+
for name in ("HTTPValidationError", "ValidationError"):
|
|
363
|
+
schema.get("components", {}).get("schemas", {}).pop(name, None)
|
|
364
|
+
return schema
|
|
365
|
+
|
|
366
|
+
return openapi
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ── factory ─────────────────────────────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
_ERRORS = {status: {"model": ErrorResponse} for status in (400, 413, 429)}
|
|
372
|
+
_NOT_FOUND = {404: {"model": ErrorResponse}}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def create_app(
|
|
376
|
+
repo: str | Path | None = None,
|
|
377
|
+
db_path: str | Path | None = None,
|
|
378
|
+
*,
|
|
379
|
+
results_path: str | Path | None = None,
|
|
380
|
+
per_net: int = 60,
|
|
381
|
+
global_limit: int = 600,
|
|
382
|
+
daily_cap: int = DAILY_CAP,
|
|
383
|
+
trusted_proxies: tuple[str, ...] | None = None,
|
|
384
|
+
clock: Callable[[], float] | None = None,
|
|
385
|
+
utcnow: Callable[[], datetime] | None = None,
|
|
386
|
+
) -> FastAPI:
|
|
387
|
+
"""Arguments override the environment (tests); the index and the store open at startup."""
|
|
388
|
+
env_proxies = os.environ.get("APIFACTS_TRUSTED_PROXIES", ",".join(DEFAULT_TRUSTED_PROXIES))
|
|
389
|
+
trusted = parse_proxies(trusted_proxies or env_proxies.split(","))
|
|
390
|
+
|
|
391
|
+
@asynccontextmanager
|
|
392
|
+
async def lifespan(app: FastAPI):
|
|
393
|
+
root = Path(repo if repo is not None else os.environ.get("APIFACTS_REPO", "."))
|
|
394
|
+
store = Store(db_path or os.environ.get("APIFACTS_DB", "observations.sqlite"))
|
|
395
|
+
app.state.apifacts = _State(
|
|
396
|
+
index=FactsIndex.load(root, os.environ.get("APIFACTS_REGISTRY_VERSION") or None),
|
|
397
|
+
store=store,
|
|
398
|
+
daily_cap=daily_cap,
|
|
399
|
+
trusted_proxies=trusted,
|
|
400
|
+
utcnow=utcnow or (lambda: datetime.now(UTC)),
|
|
401
|
+
results=Path(
|
|
402
|
+
results_path
|
|
403
|
+
or os.environ.get("APIFACTS_RESULTS")
|
|
404
|
+
or root / "var" / "results.jsonl"
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
try:
|
|
408
|
+
yield
|
|
409
|
+
finally:
|
|
410
|
+
store.close()
|
|
411
|
+
|
|
412
|
+
app = FastAPI(
|
|
413
|
+
title="apifacts",
|
|
414
|
+
version="v0",
|
|
415
|
+
summary="Verified facts about machine interfaces for AI agents (data CC BY 4.0).",
|
|
416
|
+
lifespan=lifespan,
|
|
417
|
+
)
|
|
418
|
+
limiter_kwargs = {"clock": clock} if clock else {}
|
|
419
|
+
app.add_middleware(
|
|
420
|
+
_CheckGuard,
|
|
421
|
+
limiter=RateLimiter(per_net=per_net, global_limit=global_limit, **limiter_kwargs),
|
|
422
|
+
trusted=trusted,
|
|
423
|
+
)
|
|
424
|
+
app.add_exception_handler(RequestValidationError, _on_validation)
|
|
425
|
+
app.add_exception_handler(StarletteHTTPException, _on_http)
|
|
426
|
+
app.openapi = _without_422(app.openapi)
|
|
427
|
+
|
|
428
|
+
@app.post(CHECK_PATH, response_model=CheckResponse, responses=_ERRORS)
|
|
429
|
+
def check(req: CheckRequest, request: Request) -> Response:
|
|
430
|
+
"""Facts and typed fixes for a target and (optionally) an observed error. Never empty.
|
|
431
|
+
A target outside the registered templates is answered but not stored."""
|
|
432
|
+
state = _state(request)
|
|
433
|
+
ip = _caller_ip(request.scope.get("client"), request.headers, state.trusted_proxies)
|
|
434
|
+
return JSONResponse(_check(state, req, ip, request.headers.get("user-agent")))
|
|
435
|
+
|
|
436
|
+
@app.get("/v0/services", response_model=ServicesResponse)
|
|
437
|
+
def services(request: Request) -> Response:
|
|
438
|
+
index = _state(request).index
|
|
439
|
+
payload = {
|
|
440
|
+
"services": [
|
|
441
|
+
{
|
|
442
|
+
"key": e.key,
|
|
443
|
+
"name": e.service.name,
|
|
444
|
+
"hosts": list(e.service.hosts),
|
|
445
|
+
"instance_hosts": list(instance_hosts_of(e.service)),
|
|
446
|
+
"templates": len(e.info.templates),
|
|
447
|
+
"facts": len(e.facts),
|
|
448
|
+
}
|
|
449
|
+
for e in index.services.values()
|
|
450
|
+
],
|
|
451
|
+
"registry": {"version": index.version, "license": LICENSE},
|
|
452
|
+
}
|
|
453
|
+
return _cached(request, payload)
|
|
454
|
+
|
|
455
|
+
@app.get("/v0/facts/{service}", response_model=FactsResponse, responses=_NOT_FOUND)
|
|
456
|
+
def facts(service: str, request: Request) -> Response:
|
|
457
|
+
index = _state(request).index
|
|
458
|
+
entry = index.services.get(service)
|
|
459
|
+
if entry is None:
|
|
460
|
+
raise _fail(404, "unknown_service", f"no service {service}")
|
|
461
|
+
payload = {
|
|
462
|
+
"service": entry.key,
|
|
463
|
+
"facts": [f.document for f in entry.facts],
|
|
464
|
+
"registry": _registry(index, entry),
|
|
465
|
+
}
|
|
466
|
+
return _cached(request, payload)
|
|
467
|
+
|
|
468
|
+
@app.get("/v0/facts/{service}/{slug}", response_model=FactOut, responses=_NOT_FOUND)
|
|
469
|
+
def fact(service: str, slug: str, request: Request) -> Response:
|
|
470
|
+
found = _state(request).index.fact(service, slug)
|
|
471
|
+
if found is None:
|
|
472
|
+
raise _fail(404, "unknown_fact", f"no fact af:{service}:{slug}")
|
|
473
|
+
return _cached(request, found.document)
|
|
474
|
+
|
|
475
|
+
@app.get("/healthz", response_model=Health)
|
|
476
|
+
def healthz(request: Request) -> dict[str, Any]:
|
|
477
|
+
state = _state(request)
|
|
478
|
+
return {
|
|
479
|
+
"ok": True,
|
|
480
|
+
"registry_version": state.index.version,
|
|
481
|
+
"probes_last_run": last_probe_run(state.results),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return app
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
app = create_app()
|