|
| 1 | +# Implementers Guide |
| 2 | + |
| 3 | +This guide walks through building a working VDP implementation. The |
| 4 | +[specification](specification.md) defines *what* conforms; this page is about |
| 5 | +*how* — the order to build things in, the decisions you will hit, and the |
| 6 | +mistakes that are easy to make. |
| 7 | + |
| 8 | +The spec defines three [conformance classes](specification.md#15-conformance), |
| 9 | +and an implementation may belong to more than one: |
| 10 | + |
| 11 | +| Class | Role | You are building… | |
| 12 | +|-------|------|-------------------| |
| 13 | +| [**VDP Server**](specification.md#151-vdp-server) | Produces view descriptors | an API that tells clients which templates render its responses | |
| 14 | +| [**VDP Client**](specification.md#152-vdp-client) | Consumes view descriptors | a renderer that resolves descriptors into template trees | |
| 15 | +| [**VDP BFF**](specification.md#153-vdp-bff) | A client that renders server-side | a backend-for-frontend returning finished markup | |
| 16 | + |
| 17 | +The [Go demo](https://github.com/ViewDescriptorProtocol/golang-vdp-demo) |
| 18 | +implements all three in one process and is the reference to read alongside this |
| 19 | +guide — its `vdp` package is a complete client, its `server` package a complete |
| 20 | +server and BFF. |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Implementing a VDP Server |
| 25 | + |
| 26 | +A server's job is small by design: attach a descriptor to each response. It |
| 27 | +never fetches templates and never renders. |
| 28 | + |
| 29 | +### 1. Shape your descriptors |
| 30 | + |
| 31 | +A view descriptor names a root template and, optionally, the sub-templates |
| 32 | +filling its named slots: |
| 33 | + |
| 34 | +```json |
| 35 | +{ |
| 36 | + "template": "example.com/templates/layouts/sidebar", |
| 37 | + "slots": { |
| 38 | + "mainContent": { "template": "example.com/templates/dashboard" }, |
| 39 | + "sidebarNav": { "descriptor": "/views/nav.json" } |
| 40 | + } |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +Every descriptor you emit MUST validate against the |
| 45 | +[published schema](schema.md). Wire that into CI from day one — it is the |
| 46 | +cheapest conformance test you will ever write: |
| 47 | + |
| 48 | +```bash |
| 49 | +npx ajv-cli test --spec=draft2020 \ |
| 50 | + -s vdp.v0-1.schema.json -d 'views/*.json' --valid -c ajv-formats |
| 51 | +``` |
| 52 | + |
| 53 | +### 2. Choose an identifier form |
| 54 | + |
| 55 | +A template URI is an **identity first** — a stable name and cache key — and a |
| 56 | +fetchable location only secondarily |
| 57 | +([Section 6.3](specification.md#63-template-sources)). The spec defines three |
| 58 | +forms ([Section 5.4](specification.md#54-url-resolution)): |
| 59 | + |
| 60 | +| Form | Example | Behavior | |
| 61 | +|------|---------|----------| |
| 62 | +| **(a) Absolute URI** | `https://example.com/templates/card` | Identity as written | |
| 63 | +| **(b) Relative reference** — begins with `/` or `//` | `/templates/card` | Resolved against the transport's base URL | |
| 64 | +| **(c) Scheme-less opaque identifier** | `example.com/templates/card` | Identity as written; **never resolved against a base** | |
| 65 | + |
| 66 | +Guidance: |
| 67 | + |
| 68 | +- Use **absolute URIs** when descriptors may be consumed from multiple base URL |
| 69 | + contexts (the spec's own SHOULD). |
| 70 | +- Use **path-absolute references** when templates live on the same origin as |
| 71 | + the API — descriptors stay portable across your environments (dev, staging, |
| 72 | + production) because the base URL travels with the transport. |
| 73 | +- Use **opaque identifiers** when the URI is a *name* more than an address — |
| 74 | + package-import-style identities that clients look up in a bundle or registry, |
| 75 | + supplying a scheme only if they actually fetch. |
| 76 | + |
| 77 | +Whatever you choose, remember that dot-relative values (`../templates/card`) |
| 78 | +fit **no** form: anything scheme-less that does not begin with `/` is an opaque |
| 79 | +identifier, so a conforming client will treat `..` as a host and reject it. Do |
| 80 | +not emit them. |
| 81 | + |
| 82 | +### 3. Choose a transport |
| 83 | + |
| 84 | +Any one of the [Section 4](specification.md#4-transport-mechanisms) transports |
| 85 | +satisfies conformance; which fits depends on how much you control the response |
| 86 | +body: |
| 87 | + |
| 88 | +| Your response body is… | Use | Example | |
| 89 | +|------------------------|-----|---------| |
| 90 | +| Flexible JSON (HAL, custom) | Inline `_view` / `_views` | `{"_view": {...}, "revenue": 48200}` | |
| 91 | +| Rigid (OData4, third-party formats) | `Link` header to a standalone descriptor resource | `Link: <https://example.com/views/dashboard.json>; rel="view-descriptor"` | |
| 92 | +| Rendered by exactly one template | `View-Template` header shorthand | `View-Template: example.com/templates/login` | |
| 93 | + |
| 94 | +Two transport rules trip people up: |
| 95 | + |
| 96 | +- Emit **at most one** `Link` value with `rel="view-descriptor"` per response |
| 97 | + ([Section 4.4](specification.md#44-precedence)); clients take the first. |
| 98 | +- When both an inline descriptor and a `Link` header are present, the body wins |
| 99 | + — so do not send both expecting the header to override. |
| 100 | + |
| 101 | +### 4. Serve descriptor resources well |
| 102 | + |
| 103 | +A standalone descriptor is an ordinary cacheable resource |
| 104 | +([Section 5](specification.md#5-view-descriptor-resources)). Serve it as |
| 105 | +`application/vdp+json` with real caching headers, and advertise the protocol |
| 106 | +version: |
| 107 | + |
| 108 | +```http |
| 109 | +HTTP/1.1 200 OK |
| 110 | +Content-Type: application/vdp+json |
| 111 | +Cache-Control: public, max-age=3600 |
| 112 | +ETag: "v1-dashboard" |
| 113 | +VDP-Version: 0.1 |
| 114 | +``` |
| 115 | + |
| 116 | +Independent cacheability is the point: the descriptor changes when the |
| 117 | +*presentation* changes, the data endpoint when the *data* does. |
| 118 | + |
| 119 | +### 5. Publish discovery |
| 120 | + |
| 121 | +A discovery document at `/.well-known/vdp` |
| 122 | +([Section 13.2](specification.md#132-well-known-uri)) lets clients prefetch |
| 123 | +descriptors and learn your template allowlist: |
| 124 | + |
| 125 | +```json |
| 126 | +{ |
| 127 | + "version": "0.1", |
| 128 | + "endpoints": { |
| 129 | + "/api/dashboard": { "descriptor": "/views/dashboard.json" }, |
| 130 | + "/api/products/{id}": { "descriptor": "/views/product-detail.json" } |
| 131 | + }, |
| 132 | + "trustedTemplateUrls": [ |
| 133 | + "https://example.com/templates/", |
| 134 | + "example.com/templates/" |
| 135 | + ] |
| 136 | +} |
| 137 | +``` |
| 138 | + |
| 139 | +- Serve it as `application/vdp-discovery+json` — never as |
| 140 | + `application/vdp+json`. |
| 141 | +- End allowlist entries with a trailing slash, so `…/templates/` cannot match |
| 142 | + `…/templates-evil/`. |
| 143 | +- Allowlist matching never crosses identifier forms: an absolute entry matches |
| 144 | + only absolute URIs, a scheme-less entry only opaque identifiers. **List each |
| 145 | + form you actually emit** — the example above lists both. |
| 146 | +- Endpoint keys may be Level 1 URI Templates; each `{expression}` matches one |
| 147 | + path segment, and literal keys win over templated ones. |
| 148 | + |
| 149 | +### 6. Add template metadata where it earns its keep |
| 150 | + |
| 151 | +`type` is an advisory media-type hint; `integrity` is W3C Subresource |
| 152 | +Integrity for the template bytes |
| 153 | +([Section 3.6](specification.md#36-optional-template-metadata)). Publish |
| 154 | +`integrity` for templates hosted on infrastructure you don't control — it |
| 155 | +authenticates the *content* where the allowlist only authenticates the |
| 156 | +*origin*: |
| 157 | + |
| 158 | +```json |
| 159 | +{ |
| 160 | + "template": "https://cdn.example.net/templates/chart-legend", |
| 161 | + "type": "text/x-qute", |
| 162 | + "integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" |
| 163 | +} |
| 164 | +``` |
| 165 | + |
| 166 | +The digest is computed over the exact bytes clients will fetch — |
| 167 | +`base64(sha384(body))`. |
| 168 | + |
| 169 | +### Server checklist |
| 170 | + |
| 171 | +- [ ] Every descriptor validates against the published JSON Schema |
| 172 | +- [ ] At least one Section 4 transport, following its rules (one `Link` value max) |
| 173 | +- [ ] Descriptor resources served as `application/vdp+json` with caching headers |
| 174 | +- [ ] Absolute template URIs where descriptors cross base-URL contexts |
| 175 | +- [ ] Discovery document valid per Section 13.2, served as `application/vdp-discovery+json` |
| 176 | +- [ ] Allowlist entries end with `/` and cover every identifier form emitted |
| 177 | + |
| 178 | +--- |
| 179 | + |
| 180 | +## Implementing a VDP Client |
| 181 | + |
| 182 | +A client does the real work: extract, resolve, obtain, compose, render — |
| 183 | +the [Section 8 algorithm](specification.md#8-client-resolution-algorithm). |
| 184 | +Build it in this order. |
| 185 | + |
| 186 | +### 1. Extract the descriptor |
| 187 | + |
| 188 | +Check the response in [precedence order](specification.md#44-precedence): |
| 189 | +`_view` / `_views` in the body first, then the `Link` header |
| 190 | +(`rel="view-descriptor"`, first value), then `View-Template`. Record which |
| 191 | +transport won — it determines the **base URL** for relative references: |
| 192 | + |
| 193 | +| Transport | Base URL for `/…` references | |
| 194 | +|-----------|------------------------------| |
| 195 | +| Standalone descriptor resource (`Link`) | the descriptor resource's own URL | |
| 196 | +| Inline `_view` / `_views` | the API response's URL | |
| 197 | +| `View-Template` header | the API response's URL | |
| 198 | + |
| 199 | +A malformed descriptor is rejected outright |
| 200 | +([Section 9.3](specification.md#93-invalid-view-descriptor)) — fall back to |
| 201 | +raw data rather than guessing at partial meaning. |
| 202 | + |
| 203 | +### 2. Classify every template URI |
| 204 | + |
| 205 | +This is the step implementations get wrong. Classify each value **before** |
| 206 | +touching a URL library: |
| 207 | + |
| 208 | +```text |
| 209 | +has a scheme → form (a): identity = the value, as written |
| 210 | +begins with "/" → form (b): identity = resolve(value, base) |
| 211 | +anything else → form (c): identity = the value, as written — opaque |
| 212 | +``` |
| 213 | + |
| 214 | +!!! warning "Never resolve an opaque identifier against the base URL" |
| 215 | + |
| 216 | + General-purpose URL resolution treats `example.com/templates/card` as a |
| 217 | + *relative path*. Resolved against `https://api.example.org/dashboard`, it |
| 218 | + silently becomes `https://api.example.org/example.com/templates/card` — a |
| 219 | + corrupted identity that may even pass a sloppy allowlist before 404ing. |
| 220 | + The spec forbids this: a scheme-less value not beginning with `/` names |
| 221 | + the template directly and is compared and cached **verbatim**. |
| 222 | + |
| 223 | + Watch your standard library, too: Go's `url.Parse` errors on |
| 224 | + `127.0.0.1:8080/templates/card` ("first path segment in URL cannot |
| 225 | + contain colon"), and JavaScript's `new URL(value, base)` happily |
| 226 | + mis-resolves it. Branch on the form first; parse afterwards. |
| 227 | + |
| 228 | +The identity from this step — resolved absolute URL or verbatim opaque |
| 229 | +identifier — is the template's **cache key and comparison key** everywhere |
| 230 | +downstream. A scheme is supplied only if and when you fetch |
| 231 | +([Section 6.3](specification.md#63-template-sources)). |
| 232 | + |
| 233 | +### 3. Obtain templates — from anywhere |
| 234 | + |
| 235 | +The identifier tells you *which* template; your deployment decides *where its |
| 236 | +source text comes from*. All of these are equally conforming |
| 237 | +([Section 6.3](specification.md#63-template-sources), and see the |
| 238 | +[deployment scenarios](deployment-scenarios.md)): |
| 239 | + |
| 240 | +- a bundle shipped inside the application package, |
| 241 | +- `<template>` elements delivered with the page, |
| 242 | +- a store local to the BFF or a template service, |
| 243 | +- a network fetch of the template URI itself. |
| 244 | + |
| 245 | +Select by identity, whatever the source — a template satisfied locally is |
| 246 | +indistinguishable, to the rest of the algorithm, from a fetch whose cache was |
| 247 | +warm. Network fetch is the interoperable default when no local source has the |
| 248 | +template, and every network retrieval is subject to Section 10. |
| 249 | + |
| 250 | +### 4. Enforce trust before any fetch |
| 251 | + |
| 252 | +Rendering arbitrary templates is code injection. Before fetching, validate the |
| 253 | +*identity* against the [Section 10](specification.md#10-security-considerations) |
| 254 | +allowlist chain — first source available wins: |
| 255 | + |
| 256 | +1. **Local configuration** — your own allowlist, when present. |
| 257 | +2. **Discovery document** — the API's `trustedTemplateUrls`. |
| 258 | +3. **Same-origin default** — only identities sharing the descriptor's origin. |
| 259 | + |
| 260 | +Matching ([Section 13.2](specification.md#132-well-known-uri)) is prefix |
| 261 | +matching after normalization. Practical notes: |
| 262 | + |
| 263 | +- Compare on path-segment boundaries, so a `…/templates` entry does not match |
| 264 | + `…/templates-evil`. |
| 265 | +- Lowercase the scheme and host before comparing; paths stay case-sensitive. |
| 266 | +- Forms never cross-match: opaque identifiers match only scheme-less entries, |
| 267 | + absolute URIs only absolute entries. |
| 268 | +- Reject *before* fetching. An untrusted template must never be |
| 269 | + fetched-then-discarded. |
| 270 | + |
| 271 | +And the transport rule: network retrieval MUST use HTTPS, with plain HTTP |
| 272 | +acceptable only for loopback during development. Templates from inside your |
| 273 | +own trust boundary (a bundle, the page) are exempt from all of Section 10. |
| 274 | + |
| 275 | +### 5. Verify integrity |
| 276 | + |
| 277 | +When a descriptor carries `integrity`, verify any template you fetched over |
| 278 | +the network against it — W3C SRI semantics: strongest algorithm present wins, |
| 279 | +any one matching digest of that algorithm passes, unknown algorithms are |
| 280 | +ignored. A mismatch **is a fetch failure** for that slot |
| 281 | +([Section 9.1](specification.md#91-template-fetch-failures)), not a warning. |
| 282 | + |
| 283 | +### 6. Compose, and fail small |
| 284 | + |
| 285 | +Walk the descriptor recursively — obtain the root template, fill each slot, |
| 286 | +recurse ([Section 8](specification.md#8-client-resolution-algorithm)) — under |
| 287 | +the principle **prefer partial rendering over total failure** |
| 288 | +([Section 9](specification.md#9-error-handling)): |
| 289 | + |
| 290 | +- A slot whose template cannot be obtained is **skipped**; the template's |
| 291 | + default content shows instead. The rest of the tree still renders. |
| 292 | +- A failed element of a slot *array* is skipped; the remaining elements render |
| 293 | + in declared order. |
| 294 | +- Only a **root** template failure fails the render — fall back to raw data or |
| 295 | + an error template. |
| 296 | +- Slot names with no matching insertion point are ignored (log them). |
| 297 | +- Impose a recursion depth limit (10 is the recommendation); descriptor |
| 298 | + references count toward it, and a reference chain that revisits a URL is a |
| 299 | + cycle that abandons just that slot. |
| 300 | + |
| 301 | +Cache aggressively ([Section 5.2](specification.md#52-caching)): descriptors |
| 302 | +and templates are ordinary HTTP resources, keyed by identity. |
| 303 | + |
| 304 | +### Client checklist |
| 305 | + |
| 306 | +- [ ] Extraction follows Section 4.4 precedence |
| 307 | +- [ ] The three Section 5.4 identifier forms classified correctly — opaque ids never base-resolved |
| 308 | +- [ ] Templates selected and cached by identity, from any Section 6.3 source |
| 309 | +- [ ] Allowlist chain enforced before every network fetch; no cross-form matching |
| 310 | +- [ ] HTTPS enforced for network retrieval (loopback excepted) |
| 311 | +- [ ] `integrity` verified when present; mismatch treated as fetch failure |
| 312 | +- [ ] Partial rendering on slot failure; fallback on root failure; depth limit and cycle detection |
| 313 | +- [ ] Invalid descriptors rejected; unrecognized discovery members ignored |
| 314 | + |
| 315 | +--- |
| 316 | + |
| 317 | +## Implementing a VDP BFF |
| 318 | + |
| 319 | +A BFF is a VDP Client that runs server-side |
| 320 | +([Section 7.5](specification.md#75-bff-backend-for-frontend-pattern)): it |
| 321 | +meets **every** client requirement above in its role as consumer of upstream |
| 322 | +APIs, then returns finished markup. VDP places no constraints on the interface |
| 323 | +it exposes downstream — the browser never sees a descriptor. |
| 324 | + |
| 325 | +What changes in practice: |
| 326 | + |
| 327 | +- **Negotiate for your platform.** Send `VDP-Platform` (and standard content |
| 328 | + negotiation) on descriptor fetches so the API can select the right template |
| 329 | + tree ([Section 5.5](specification.md#55-client-specific-selection)). |
| 330 | +- **Cache across users.** Descriptors and templates are shared, cacheable |
| 331 | + state; per their HTTP headers, one warm cache serves every request. |
| 332 | +- **Keep failure behavior.** Section 9's partial rendering applies to the page |
| 333 | + you assemble: a dead slot ships as the template's default, not as a 500. |
| 334 | + |
| 335 | +--- |
| 336 | + |
| 337 | +## Testing your implementation |
| 338 | + |
| 339 | +**Servers:** schema-validate every descriptor and discovery document you emit |
| 340 | +(the [ajv commands](schema.md#validation) mirror the spec repo's CI), then |
| 341 | +check the transport rules — exactly one `Link` value, correct media types, |
| 342 | +`VDP-Version` where you advertise support. |
| 343 | + |
| 344 | +**Clients:** the hard cases are the failure paths and the identifier forms. |
| 345 | +The [Go demo](https://github.com/ViewDescriptorProtocol/golang-vdp-demo) |
| 346 | +serves ready-made vectors — run it locally and point your client at: |
| 347 | + |
| 348 | +| Endpoint | Exercises | Your client should | |
| 349 | +|----------|-----------|--------------------| |
| 350 | +| `/api/dashboard` | Link transport, form (b) refs, §3.7 reference, integrity | Render a four-level tree | |
| 351 | +| `/api/dashboard?fail=chart` | One slot's template 404s | Skip the slot, render the rest | |
| 352 | +| `/api/dashboard?fail=root` | Root template 404s | Fall back to raw data | |
| 353 | +| `/api/dashboard?fail=integrity` | SRI mismatch | Treat as fetch failure, skip the slot | |
| 354 | +| `/api/dashboard?untrusted` | Off-allowlist template | Reject **without fetching** | |
| 355 | +| `/api/odata/products` | Link + OData annotation, form (c) opaque identifier | Keep the identifier verbatim as cache key | |
| 356 | +| `/api/login` | `View-Template` shorthand | Render the single template | |
| 357 | +| `/api/products/42?view=compact` | Multiple named views | Select the requested view, default otherwise | |
| 358 | + |
| 359 | +And remember what is deliberately **not** VDP's job — do not build it into |
| 360 | +your implementation: conditional slot logic, template parameters, and |
| 361 | +data-to-template field mapping all belong to the server's descriptor choice or |
| 362 | +the template engine, never to the protocol |
| 363 | +([Design Decisions](specification.md#design-decisions)). |
| 364 | + |
| 365 | +*[VDP]: View Descriptor Protocol |
| 366 | +*[HAL]: Hypertext Application Language |
| 367 | +*[BFF]: Backend for Frontend |
| 368 | +*[SRI]: Subresource Integrity |
0 commit comments