Skip to content

stacs.verify

What a STAC API serves, compared with what is published.

Two comparisons, and neither is a count:

  • id sets, in both directions. A /search on a list of ids silently omits the ids it does not have, so "asked for N, got N" can be true while the sets differ.
  • bodies, by digest. A rebuild keeps every id, so equal id sets say nothing about whether the API serves the body that was published.

Every way a digest can be absent raises rather than comparing: an unread body is an error, never "unchanged".

The API base URL and the collection id are always the caller's. Nothing here defaults to a deployment.

DigestError

Bases: ValueError

A body that has no canonical form: NaN or Infinity, a number beyond a double, or a string that is not valid Unicode. Raised with the item's id, never compared.

ids_registered(collection_id, api, page_size=PAGE_SIZE, session=None)

Every item id the API currently serves for a collection.

Keyset paging over POST /search with fields:{include:["id"]}.

bodies_registered(collection_id, api, page_size=PAGE_SIZE, session=None)

{id: body_digest} for every item the API serves in a collection.

The same enumeration as ids_registered, but of FULL bodies -- no fields include, since a digest of an id-only stub would compare equal to nothing and differ from everything. Measured 2026-09-28: a 10,000-item page is 18 MB and 29 s, so a 102k-item catalogue is ~5.5 min. Only the digest is kept, so memory is ~100 bytes an item rather than the body.

A repeated id raises. The API's primary key makes one impossible, so seeing one means the paging overlapped -- and a dict keyed by id would have collapsed it silently into a single entry.

search_body(ids, collection_id, ids_only=True)

The POST /search body for an id lookup, scoped to one collection.

Pure, and separated out so it can be asserted on offline. Two parameters, each of which exists because omitting it fails silently.

limit: the API's DEFAULT LIMIT IS 10, so a body without one silently returns the first 10 of however many ids were asked for. That reads as "590 of my 600 items are missing". Measured against a live API: 600 registered ids, no limit -> 10 features; limit=600 -> 600.

collections: without it a /search asks "is this id served ANYWHERE", which is a different question from the one every caller means. Two collections sharing ids on one endpoint (a rename, mid-cutover) make an unscoped verification pass on the old collection's rows while zero items registered into the new one. The collection id is required rather than defaulted, because a default is exactly the thing that would go unnoticed.

ids_only=False drops the fields include, for a caller comparing content: the digest of an id-only stub matches no real body.

Examples:

>>> search_body(["a", "b"], "my-collection")
{'collections': ['my-collection'], 'ids': ['a', 'b'], 'limit': 2,
 'fields': {'include': ['id']}}
>>> search_body(["a"], "")
Traceback (most recent call last):
...
ValueError: collection_id is required: an unscoped /search answers about every ...

bodies_serving(ids, collection_id, api, chunk=500, session=None)

{id: body_digest} for those of these ids the API serves IN THIS COLLECTION.

Batched because a very long id list is a real request-size ceiling. An id the API does not serve is simply absent from the result -- a /search omits ids that do not exist without erroring -- so the caller compares key SETS, never a count, and then the digests.

ids_diff(published, registered)

(missing, orphaned) — reported in BOTH directions.

missing = published but not registered (the API is behind the catalogue) orphaned = registered but not published (a delete never propagated)

Set equality is the only sound check here: a /search on a list of ids returns the ones that exist and silently omits the rest, so asserting on the returned count passes vacuously.

Examples:

Equal counts, unequal sets:

>>> ids_diff(["a", "b"], ["b", "c"])
(['a'], ['c'])

canonical_json(doc)

The bytes a STAC object's digest is taken over: RFC 8785 (JCS) serialisation of the object with links removed and the round-trip differences canonicalised.

RFC 8785 because stacchain's Merkle tooling hashes JCS, so a later Merkle layer can reuse these leaf hashes. Nothing stores a digest -- both sides are recomputed from the published bodies and the API on every run -- so the form can change with no migration.

Examples:

Keys sorted, links gone, a null member dropped, 1.0 written as 1:

>>> canonical_json({"id": "a", "links": [{"rel": "self"}], "b": None, "a": 1.0})
b'{"a":1,"id":"a"}'

body_digest(doc)

sha256 of canonical_json(doc).

links is the one member the API rewrites: it replaces the published collection link with its own self/root/parent/collection set on its own host. Everything else round-trips through pgstac apart from the differences _canonical absorbs.

Refuses anything but an object. A None that hashed would equal every other None.

Examples:

>>> body_digest({"id": "a"})
'8489a5deb454a360345c7868bca8672de92b446caf3d3b014af6a56e3d549d30'
>>> body_digest({"id": "a", "links": []}) == body_digest({"id": "a"})
True
>>> body_digest(None)
Traceback (most recent call last):
...
TypeError: expected a JSON object, got NoneType

content_diff(published, registered)

(missing, orphaned, changed) from two {id: digest} maps.

changed = in both, digests differ. An empty or None digest on EITHER side raises: it means a body was never read, and two unread bodies must not compare equal.

Examples:

>>> content_diff({"a": "d1", "b": "d2"}, {"b": "d3", "c": "d4"})
(['a'], ['c'], ['b'])
>>> content_diff({"a": ""}, {"a": ""})
Traceback (most recent call last):
...
ValueError: 1 published id(s) have no body digest, e.g. ['a']: a body was not read

collection_state(collection_file, collection_id, api, session=None)

'same', 'changed' or 'missing': the registered collection vs the file.

404 is 'missing' (never registered). Any other failure raises after retries, so an unreachable API cannot read as 'same'.

queryables_served(collection_id, api, session=None)

{name: fragment} the API serves at /collections//queryables, for a collection the API has.

A collection with no queryables at all is served "properties": null, which is none. A 404 is not: for a collection that exists it means the API does not serve queryables (stac-fastapi-pgstac without its filter extension), and read as "none" it would report every declared queryable missing on every run. So it raises, as does every other way of not reading the document.

collections_listed(api, session=None)

Every collection id the API lists at /collections, following next links.

stac-fastapi pages /collections (10 to a page by default), so the first page alone is a sample. Every way out other than "no next link" raises, as in _search_pages.