Add CovJSON API design alternatives analysis#26
Conversation
Adds docs/07-api-design-alternatives.md: a grounded comparison of three non-bespoke directions for the titiler-covjson HTTP surface (TiTiler-native output format, hybrid EDR-vocabulary extension, and full OGC API-EDR conformance), contrasted with the current bespoke design in doc 02. The Grid model layer (helpers/input/modeler) is implemented and tested, but there is no HTTP layer yet, so this settles the endpoint shape before the first end-to-end slice and Docker container. Recommends Option B (hybrid EDR-vocabulary FactoryExtension) with a first /cube Grid endpoint; the one open decision is whether EDR conformance is a project goal. Does not change 02-api-definition.md; proposes how it might evolve. Claims are grounded against the installed titiler.core 0.24.2, the OGC API-EDR standard, and the CoverageJSON spec (see the doc's Sources).
| ### 2.3 One practical wrinkle: `.{format}` is not free real estate | ||
|
|
||
| TiTiler's image endpoints select output via a `.{format}` path suffix bound to | ||
| the `ImageType` enum and its render pipeline (`png`, `tif`, `jpeg`, `npy`, ...). | ||
| **CovJSON is not an `ImageType`** and is not produced by the render pipeline, so | ||
| it cannot ride that suffix machinery directly. Whichever option we pick, the | ||
| extension therefore registers its **own** routes and selects the format itself | ||
| rather than through the image `.{format}` suffix -- the selection mechanism is | ||
| settled in Section 7.1 (`f=CoverageJSON` or `Accept`). This is a small but real | ||
| constraint that rules out "just add `.covjson` to the existing image route for | ||
| free." |
There was a problem hiding this comment.
Strong agree on the selection mechanism (f=/Accept, no suffix). One reframe: .{format} isn't really blocked by the "Image" concept — npy already rides that suffix and is not an image either. The actual blocker is narrower: CovJSON isn't a member of the ImageType enum / render pipeline, so it's a format-registration problem, not an Image-semantics one. I wouldn't lean on the "not an image" framing; lean on "not in the ImageType enum / render pipeline." Conclusion (own routes + f/Accept) is unchanged.
There was a problem hiding this comment.
f=/Accept is definitely a more OGC friendly way. 👍
There was a problem hiding this comment.
Reframed in Section 2.3: the blocker is now stated as "not a member of the ImageType enum / render pipeline" (with npy as the example of a non-image that does ride the suffix) — a format-registration gap, not image-semantics. Conclusion unchanged.
There was a problem hiding this comment.
Confirmed as the selector in Section 7.1 (f=CoverageJSON else Accept, no path suffix), reusing TiTiler's existing f-else-Accept idiom.
|
|
||
| EDR conformance is modular: a server must implement **at least one** query type, | ||
| plus the Core/Collections plumbing (a landing page, `/conformance`, and a | ||
| `/collections` model with per-collection `data_queries`, `parameter_names`, and | ||
| extents). That plumbing is the line between "EDR-flavored" and "EDR-conformant" | ||
| -- the key axis separating Options B and C below. |
There was a problem hiding this comment.
Proposing we anchor this API on titiler-stacapi (https://developmentseed.org/titiler-stacapi/) as the primary deployment model:
- Collection-scoped routes give us EDR
collectionIdsemantics for free (and make the/collections/{id}/cubeconformant paths reachable without net-new collections plumbing). - Resolving a collection to its STAC items gives us the
datetime/taxis the current 2-DGridInputis missing — so/cubez/t and PointSeries get a real backing instead of a TODO.
Implication for the extension: it must register against two dependency surfaces — titiler-stacapi's collection_id/item-search deps (the EDR, collection-scoped path) and titiler.core's url=/DatasetParams (the single-dataset escape hatch). Worth stating that dual-mount explicitly in the spec so it's a deliberate design, not two divergent code paths.
There was a problem hiding this comment.
Partly adopted, partly reframed — flagging the divergence. The collection-scoped t-axis backing is captured, but as one option behind a resolver seam (Section 7.6) rather than as the primary deployment with the dependency taken now. Reasoning: the t-axis is real titiler-stacapi runtime behavior (a genuine eventual dependency), but slice 1 stays dependency-free against a single COG via url=, which remains a permanent path alongside any collection backing. Your "two surfaces" become "one seam, swappable backing, url= always available." If you want titiler-stacapi committed as the primary surface sooner, that's a product call I'd rather make explicitly than bake in — let me know.
There was a problem hiding this comment.
Basically, we don't have to commit to adding titiler-stacapi as a dependency right now. We can keep our aim to reach an end-to-end capability lean by avoiding adding it now without blocking us from adding it later.
| - **Pros:** recognizable, standards-aligned vocabulary; interoperable with EDR / | ||
| CovJSON tooling at the query-shape level; future-proof -- a clean, additive | ||
| path to full conformance (Option C) without renaming endpoints; does not force | ||
| the heavy Collections model on day one. | ||
| - **Cons:** more up-front concept mapping than Option A (WKT parsing, EDR param | ||
| names); "EDR-flavored but not conformant" can mislead clients that probe | ||
| `/conformance` -- we must be explicit that conformance is not yet claimed. |
There was a problem hiding this comment.
Worth being precise about what Option B's "interop at the query-shape level" delivers on its own: EDR query types are resources under /collections/{collectionId}/{queryType}, so a bare top-level /cube is not a path any EDR client discovers. B over a plain factory therefore buys EDR parameter vocabulary (coords, parameter-name, f) but not EDR path-level interop.
The good news (see my note on building this on titiler-stacapi): once the surface is collection-scoped, that path-level interop comes essentially for free — which is also why I think the B→C distance is smaller than the matrix suggests. Can we state explicitly which interop B delivers standalone vs. via collection scoping, so we don't oversell discoverability?
There was a problem hiding this comment.
Section 3 now separates the two levels explicitly: B standalone delivers EDR parameter interop (coords, parameter-name, f); path-level discoverability needs collection scoping (C, or B mounted collection-scoped). The matrix has two distinct interop rows, and Option B's Pros/Cons say so directly. The smaller B→C distance is noted in Option C and the matrix.
| 4. **Aggregation vs extraction (the doc-02 `format=aggregated` conflation).** | ||
| doc 02 Section 3.2 overloads one `/bbox` endpoint with a `format` flag that | ||
| switches between two unrelated operations: `format=full` *extracts* the raster | ||
| cells (a `Grid`, shape `[H, W]`), while `format=aggregated` *reduces* them to a | ||
| single statistic (mean/median/...) returned as a `Polygon` (shape `[1]`). They | ||
| differ in operation (extraction vs reduction), output domain (`Grid` vs | ||
| `Polygon`), shape (array vs scalar), and which parameters apply -- so bundling | ||
| them behind one `format` flag forces a caller to read `format` to know whether | ||
| they get an array or a scalar. Both TiTiler (`/bbox` vs `/statistics`) and EDR | ||
| (an `area`/`cube` query vs a separate aggregation concern) keep these apart; we | ||
| should too: make aggregation its own endpoint, and do not let it gate the Grid | ||
| slice. | ||
|
|
||
| Note: this is **no longer an upstream blocker.** `Polygon` and `PolygonSeries` | ||
| landed in covjson-pydantic 0.8.0 (now the project's pinned floor and the | ||
| installed version), so the aggregated path is buildable today; it should simply | ||
| be a *separate* endpoint rather than a `format` mode on Grid extraction. | ||
| (`Section`, `MultiPolygon`, and `MultiPolygonSeries` remain absent from the | ||
| upstream `DomainType` enum, but none are needed here.) |
There was a problem hiding this comment.
I think there's a genuine misunderstanding here, and it's the most important one. The aggregation I originally intended is not a statistical reducer (mean/median → scalar Polygon). It's downsampling a large extent to reduced values — Map-tile-as-CovJSON. Real case: ground-motion / PSI over a large bbox with millions of points; we cannot extract the ndarray at full resolution, so we aggregate onto a reduced width × height Grid. That's still a Grid (array), not a scalar — closer to TiTiler's part(width,height) / overview path than to /statistics.
Two asks: (1) confirm this reduced-resolution Grid capability is preserved as a first-class concern in the new design (it looks like it survives via the factory's width/height/max_size, but I don't want it folded into "statistics"); and (2) note the harder case — for vector point clouds (PSI), reducing to a grid is spatial binning, a different pipeline than rio-tiler raster resampling, and arguably an EDR extension rather than core. If EDR doesn't cover "tile-as-coverage aggregation," that's fine — let's name it as our extension.
There was a problem hiding this comment.
Section 7.4 now names three operations: full-resolution extraction, reduced-resolution Grid (downsampling via part(width,height)/max_size — first-class, explicitly not /statistics), and statistical reduction. The reduced-resolution Grid is preserved as a first-class concern, and the vector point-cloud case is called out as spatial binning — a separate pipeline, out of scope for the raster path, named as our own EDR extension ("tile-as-coverage aggregation").
| - **Band selection can stay TiTiler-native for now.** Adopting EDR's | ||
| `parameter-name` as the selector (vs TiTiler's `bands`/`bidx`/`expression`) | ||
| is not required for the slice; keep the TiTiler parameters and revisit | ||
| `parameter-name` only if/when full EDR conformance (Option C) is pursued. |
There was a problem hiding this comment.
Genuine question, not rhetorical: what actually blocks mapping EDR parameter-name onto TiTiler's bands/bidx/expression now? It reads like a thin alias over the existing band-selection dependency.
My worry is that deferring it undercuts the headline "rename-free growth path" claim: if we ship /cube with bands=/bidx= and later go conformant, we do introduce a new public selector (parameter-name) — exactly the churn B is supposed to avoid. If it's cheap, I'd rather accept parameter-name as an alias from day one (keeping bands working) so the public vocabulary is stable.
There was a problem hiding this comment.
Agreed — nothing blocks it. Section 7.6 adopts parameter-name from day one as an alias for band_names (with bands/bidx for indices and expression for band math), so the parameter vocabulary is stable before any conformance step.
| ### Option C -- Full OGC API-EDR conformance | ||
|
|
||
| Commit to EDR as the API contract: landing page, `/conformance`, a | ||
| `/collections` model with per-collection `data_queries`, `parameter_names`, | ||
| spatial/temporal/vertical extents, `/instances` for time-versioned data, and the | ||
| query types as conformant resources, with CovJSON as the primary `f` encoding. | ||
|
|
||
| - **Format selection:** the standard OGC API `f`/`Accept` mechanism; plus full | ||
| content negotiation and the collection-description machinery. | ||
| - **Reuse:** moderate -- reader reuse as in B, but a substantial amount of | ||
| net-new EDR plumbing (collections, conformance, metadata) that has no TiTiler | ||
| equivalent to inherit. | ||
| - **Pros:** the most interoperable and discoverable outcome; a real, certifiable | ||
| OGC API that EDR clients consume with zero custom glue; clearest long-term | ||
| story. | ||
| - **Cons:** largest scope and slowest to a first end-to-end slice; the | ||
| Collections/conformance model is a meaningful design effort in its own right; | ||
| arguably over-scoped for a TiTiler *extension* whose first job is simply to | ||
| emit CovJSON; risks blocking the near-term goal (prove the Grid path in a | ||
| container). | ||
| - **Effort:** highest. | ||
| - **Growth path:** terminal -- this *is* the conformant end state. |
There was a problem hiding this comment.
Following up on the collection-identity concern: I think we resolve it by building the EDR surface on titiler-stacapi rather than reconciling url= with a hand-rolled collections model. Its factories are already collection-scoped (/collections/{collection_id}/…) and backed by a configured STAC API, so STAC collection IDs serve directly as EDR collectionIds — consistent with doc 02's existing "STAC-first" framing. We keep a url= path as a single-dataset escape hatch via a plain TilerFactory.
Net effect: the data-identity cost I'd otherwise worry about for Option C mostly disappears, which further shortens the B→C distance. Suggest a sentence here noting that C's real effort is not a bespoke collections model if we adopt titiler-stacapi.
There was a problem hiding this comment.
The sentence you asked for is in Option C: if collection identity comes from a STAC backing, STAC collection IDs serve directly as EDR collectionIds, so C's collections model isn't bespoke — which is why the B→C distance is shorter than the matrix's raw "High" suggests. Stated conditionally ("if STAC-backed") to match the resolver-seam framing in the related thread rather than committing the dependency.
|
|
||
| ```python | ||
| @define | ||
| class FactoryExtension(metaclass=abc.ABCMeta): | ||
| @abc.abstractmethod | ||
| def register(self, factory: "BaseFactory"): ... | ||
| ``` | ||
|
|
||
| An extension's `register()` adds routes onto an existing factory's router, so the | ||
| new routes inherit that factory's dependencies. This is the same mechanism | ||
| TiTiler's own first-party extensions use -- e.g., `cogValidateExtension`, | ||
| `cogViewerExtension`, `stacViewerExtension`, `wmsExtension`, and `wmtsExtension` | ||
| in the `titiler.extensions` package (see Sources). All three options below are | ||
| delivered as a `FactoryExtension`; | ||
| they differ only in the **endpoint names/vocabulary and the conformance | ||
| commitment**, not in the attach mechanism or the format selector (which is shared | ||
| -- Section 7.1). |
There was a problem hiding this comment.
Proposing we add a cross-cutting decision: the mount prefix should be a factory-level setting, not hard-coded. For a pure-EDR deployment you'd want it unprefixed (or /edr); inside a standard TiTiler deploy you'd want to choose a prefix (/coverage, /edr, …) to sit alongside the image endpoints. Please expose this as a setting on the FactoryExtension (with a sensible default) rather than baking a fixed path.
There was a problem hiding this comment.
Done — Section 7.7. BaseFactory already exposes router_prefix, so the mount prefix is a factory setting with a sensible default, not a baked path.
| 2. **First Grid endpoint = `/cube`?** *Recommend yes.* `/cube` (bbox + optional | ||
| z/t) maps directly onto the existing bbox `Reader.part()` -> `GridInput` path | ||
| and parses a plain `bbox` rather than Well-Known Text geometry (Section 7.6). | ||
| `/area` (polygon via `coords`) is the more general EDR primitive and can follow | ||
| via `Reader.feature()`. |
There was a problem hiding this comment.
Heads-up on a naming-expectation trap. EDR cube is semantically bbox + z + datetime (a 3-D/4-D hypercube), but the implemented GridInput is (bands, height, width) — purely 2-D + bands, no z/t axis. If we expose /cube for the first slice, EDR-aware clients will reasonably expect z/datetime to do something.
Two clean exits: (a) accept z/datetime as validated no-ops for now and document the 2-D limitation, or (b) name the first slice for what it actually returns (a 2-D area/grid) and promote to /cube when z/t land. I'd prefer we not ship an EDR verb whose dimensionality we don't yet honor. (Note: building on titiler-stacapi gives us the natural t-axis source — a collection resolves to STAC items carrying datetime — so option (a) becomes realistic rather than a TODO.)
There was a problem hiding this comment.
Took your exit (b): Section 7.6 names the first slice for what it returns (2-D /bbox → Grid) and promotes to /cube only when a real z/t axis backs it. Exit (a) — z/datetime as documented no-ops — is explicitly rejected as dishonest while the dimensionality is unhonored.
| | Answer to "is EDR conformance a goal?" | Recommended option | First Grid endpoint | What the answer changes | | ||
| | --- | --- | --- | --- | | ||
| | **Yes** -- a committed goal | **B now, on a planned road to C** | `/cube` | Build the EDR-vocabulary slice first, then add the Collections / `/conformance` plumbing as committed follow-on work, and start sketching collection metadata sooner. Do *not* build all of C before the first slice. | | ||
| | **Maybe** -- open but uncommitted | **B** (the default recommendation) | `/cube` | Nothing extra now. B keeps C reachable later without renaming endpoints, so the decision is deferrable at no cost. | | ||
| | **No** -- an explicit non-goal | **A** (TiTiler-native) | `/bbox/{minx},{miny},{maxx},{maxy}` via `?f=CoverageJSON` | Drop the EDR vocabulary (its interop / conformance payoff is moot) and use TiTiler-native endpoint names, with the `f=CoverageJSON` / `Accept` selection from Section 7.1 (no `/coverage` path suffix). | |
There was a problem hiding this comment.
My answer to the one decision: "maybe" — open but uncommitted, which keeps your Option B recommendation. I want to be precise that this is not a "no": my gut said no, but the more I read this, the more I think anchoring on the OGC standard makes our implementation more robust and better-specified, even if we never certify. So: not a committed goal, but an explicit non-rejected future direction → Option B, with C kept reachable.
Flagging because under this table a literal "no" would point to Option A, which isn't what I want.
There was a problem hiding this comment.
Recorded as "maybe" in the NOTE box, the Section 8.1 table (marked as the answer given), and Section 9 Q1. The decision guide makes clear "maybe" → Option B, with only an explicit "no" pointing to A.
|
Thank you for this — genuinely excellent work, and exactly the kind of "settle the shape before the code" rigor I was hoping for. The grounding against installed Decision on the one open question (Q1): conformance is a "maybe" — open, uncommitted, but explicitly not a "no". I want OGC API-EDR as a guiding standard for robustness even if we never certify. So I'm endorsing Option B with a Architecture base: I'd like us to anchor the EDR surface on titiler-stacapi. Its collection-scoped routes give us EDR My inline notes net out to a few things I'd like reflected before we write the endpoint spec:
On the secondary ask: yes to lightweight |
|
|
||
| All three options build on the same substrate, stated once here. | ||
|
|
||
| ### 2.1 `TilerFactory` already implements the query taxonomy |
There was a problem hiding this comment.
I wouldn't not directly extend the TilerFactor or create an extension but create a new Factory based on titiler.core.factories.BaseTilerFactory like https://github.com/developmentseed/titiler-stacapi/blob/14cb0348d62a34f7ea36f95bff885175de19e87b/titiler/stacapi/factory.py#L313
you don't get the regular endpoints but they are usually easy to copy/paste
There was a problem hiding this comment.
Adopted — Section 2.2 is rewritten around a dedicated factory subclassing titiler.core.factory.BaseFactory with its own register_routes(), citing OGCEndpointsFactory(BaseFactory) as the exemplar. One correction for the record: in the pinned titiler.core (0.24.2) the base is named BaseFactory (renamed from BaseTilerFactory in 0.19.0), and conforms_to arrived in 0.22.0 — so this is also why the dependency floor has to rise (Section 7.8 / #27).
|
Conformance recorded as "maybe" → Option B confirmed, C kept reachable. The doc is reformulated against the feedback; ADR scaffolding (
Two places I went a different way than your summary, deliberately, and want to flag rather than paper over:
ADR scaffolding is in — ADR-0001 records the direction. Follow-on doc 02 / doc 05 rewrites and the |
Add docs/adr/ -- a README (numbering, when-to-write rules) and a MADR-lite template -- plus ADR-0001 recording the chosen direction: Option B (EDR-vocabulary surface) delivered as a dedicated titiler.core BaseFactory subclass, an honest 2-D /bbox first slice with /cube deferred until z/t is backed, and a resolver seam with titiler-stacapi as one optional temporal backing. docs/07-api-design-alternatives.md remains the supporting study. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Incorporate reviewer feedback (Emmanuel Mathot, Vincent Sarago) on the CovJSON HTTP API direction: - Mechanism: deliver the surface as a dedicated titiler.core BaseFactory subclass rather than a FactoryExtension grafted onto TilerFactory (Section 2.2), with reuse at the dependency-injector level, not route inheritance. - First slice: an honest 2-D /bbox Grid route; defer the EDR /cube verb until a real z/t axis backs it (Section 7.6), instead of shipping a hypercube verb whose dimensionality we cannot honor. - Interop: separate parameter-level (Option B, free) from path-level (collection-scoped / Option C) interoperability throughout (Section 3, matrix), and caveat the "rename-free" claim to the one planned /bbox -> /cube transition. - Aggregation: disentangle into three operations -- full-resolution extraction, reduced-resolution Grid downsampling (first-class, not statistics), and statistical reduction -- and subsume the bespoke /overview endpoint; name vector point-cloud binning as a separate pipeline / future extension (Section 7.4). - Adopt EDR parameter-name as a band_names alias from day one; make the mount prefix a factory setting via router_prefix (Sections 7.6, 7.7). - Record the resolver seam (url= today, titiler-stacapi/xarray as optional temporal backings) and the titiler.core dependency floor / issue #27 prerequisite (Sections 7.6, 7.8); reframe the ImageType selection wrinkle (Section 2.3); record Q1 = "maybe" -> Option B. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the full ADR lifecycle (Proposed, Accepted, Rejected, Deprecated, Superseded) in the template and README, and number ADRs in order of creation rather than acceptance so a Proposed ADR can hold its number before ratification. Mark ADR-0001 as Proposed while PR #26 is under review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks @emmanuelmathot and @vincentsarago. I've made adjustments to the proposal and also added an ADR. Please have another look and let me know if you have any further suggestions. |
What this is
A design alternatives analysis (an RFC, not code) for the titiler-covjson HTTP surface, added as
docs/07-api-design-alternatives.md. The Grid model layer (helpers/input/modeler) is implemented and tested, but there's no HTTP layer yet, so this will settle the endpoint shape before we wire the first end-to-end slice and a Docker container.It compares three non-bespoke directions (TiTiler-native output format; hybrid EDR-vocabulary extension; full OGC API-EDR conformance), contrasts them with the current bespoke design in doc 02, and lands on a recommendation. Every factual claim is grounded against the installed
titiler.core0.24.2, the OGC API-EDR standard, and the CoverageJSON spec (see the doc's Sources).This PR does not change
02-api-definition.md; it proposes how that doc might evolve once we agree on a direction.Review ask
The doc opens with a "How to review this" NOTE box giving a tiered reading path for limited time. The short version:
FactoryExtension) with a first/cubeGrid slice; only an explicit "no" to conformance changes that.Please comment inline wherever you disagree, and answer the conformance question (Section 8.1) so we can pick the path. Next step after that is a short spec for the chosen Grid endpoint, then implementation.
Secondary ask
Separately, are you interested in setting up some lightweight ADR scaffolding (a
docs/adr/folder with a short MADR template) to record this and future decisions as 1-page records? This analysis would remain the supporting study; a short ADR would capture just the chosen direction. Happy to scaffold it if you're keen, or skip it for now.