Skip to content

Instantly share code, notes, and snippets.

@jnbdz
Created February 27, 2026 03:08
Show Gist options
  • Select an option

  • Save jnbdz/0f6241f6829ccb212100fd552566bbf5 to your computer and use it in GitHub Desktop.

Select an option

Save jnbdz/0f6241f6829ccb212100fd552566bbf5 to your computer and use it in GitHub Desktop.

View Descriptor Protocol (VDP)

Status: Working Draft Version: 0.1.0

Abstract

The View Descriptor Protocol (VDP) defines a standard mechanism for associating API data responses with the templates that should render them. A view descriptor is a JSON structure that identifies a root template by URL and declares how sub-templates compose into named slots, forming a recursive template tree. View descriptors can be transported via HTTP headers (for constrained formats like OData4) or inline in the response body (for flexible formats like HAL+JSON). The protocol is framework-agnostic — templates can be HTML/Qute, SwiftUI views, Compose layouts, or any other rendering format.

1. Problem Statement

REST APIs return structured data (JSON, XML) that carries no presentation information. The client must independently decide how to render this data — typically by hardcoding template choices into client logic. This creates tight coupling between API consumers and their rendering layer.

VDP solves this by letting the server declare:

  • Which template(s) to use for rendering a response
  • How templates compose together (which sub-template fills which slot)

VDP explicitly does NOT define:

  • How templates bind to data (that is the template engine's job — Qute expressions, JSONPath, etc.)
  • Styling or CSS class information (that belongs in the template itself)
  • Client-side state management

2. Terminology

  • View Descriptor: A JSON object that describes a template tree — a root template URL and its slot assignments.
  • Template URL: A URL identifying a template resource. The URL MUST resolve to a renderable template in the client's rendering framework.
  • Slot: A named insertion point in a template where a sub-template can be composed. Slot names correspond to the template's own insertion point identifiers (e.g., Qute's {#insert slotName}, HTML's <slot name="slotName">).
  • View Descriptor Resource: A standalone JSON document containing a view descriptor, addressable by its own URL, cacheable independently of the data it describes.
  • Static Composition: Template includes that are hardcoded within the template itself (e.g., a layout always including its _head.html partial). VDP does not manage these — they are the template's internal concern.
  • Dynamic Composition: Template slots whose content varies per API response. VDP manages these.

3. View Descriptor Format

3.1 Basic Structure (Single Template)

The simplest view descriptor points to a single template with no slots:

{
  "template": "https://example.com/templates/article.html"
}

3.2 Template Composition (Slots)

When a template has named insertion points that should be filled dynamically, the view descriptor declares a slots object. Each key is a slot name matching an insertion point in the template, and each value is itself a view descriptor:

{
  "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/layouts/sidebar.html",
  "slots": {
    "mainContent": {
      "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
    },
    "sidebarNav": {
      "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/navigation/nav.html"
    }
  }
}

This tells the client: "Render sidebar.html, and fill its mainContent slot with card.html and its sidebarNav slot with nav.html."

3.3 Recursive Nesting

Since each slot value is itself a view descriptor, composition nests to arbitrary depth:

{
  "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/layouts/sidebar.html",
  "slots": {
    "mainContent": {
      "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/demos/dashboard.html",
      "slots": {
        "statsRow": {
          "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
        },
        "activityTable": {
          "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/table.html"
        },
        "chart": {
          "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/charts/chart.html",
          "slots": {
            "legend": {
              "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/charts/chart-legend.html"
            }
          }
        }
      }
    },
    "sidebarNav": {
      "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/navigation/nav.html"
    }
  }
}

3.4 Multiple Views

A single API response may offer multiple views (e.g., a summary view and a detail view, or views for different device classes). Use a named object at the top level:

{
  "views": {
    "default": {
      "template": "https://example.com/templates/product-detail.html"
    },
    "compact": {
      "template": "https://example.com/templates/product-card.html"
    },
    "mobile": {
      "template": "https://example.com/templates/product-mobile.html",
      "slots": {
        "gallery": {
          "template": "https://example.com/templates/components/swipe-gallery.html"
        }
      }
    }
  }
}

When only a single view is needed, the top-level object IS the view descriptor (no views wrapper). When multiple views are present, the views key wraps them. A client SHOULD use default when no specific view is requested.

3.5 Slot Arrays

A single slot can accept multiple templates, rendered in sequence within the insertion point. This is useful when composing multiple independent components into a single region:

{
  "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/layouts/sidebar.html",
  "slots": {
    "mainContent": [
      {
        "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
      },
      {
        "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/charts/chart.html"
      },
      {
        "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/table.html"
      }
    ],
    "sidebarNav": {
      "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/navigation/nav.html"
    }
  }
}

Each element in the array is a full view descriptor and can itself have nested slots. The client MUST render array elements in order.

3.6 Formal Grammar

ViewDescriptor      = { "template": TemplateURL, "slots"?: Slots }
TemplateURL         = URI (RFC 3986)
Slots               = { SlotName: SlotValue, ... }
SlotName            = string (matches an insertion point in the template)
SlotValue           = ViewDescriptor | ViewDescriptor[]

MultiViewDescriptor = { "views": { ViewName: ViewDescriptor, ... } }
ViewName            = string

A valid VDP payload is either a ViewDescriptor or a MultiViewDescriptor.

4. Transport Mechanisms

VDP supports two transport modes. Servers MAY use either or both.

4.1 HTTP Link Header (Standalone Resource)

The server responds with a Link header pointing to a view descriptor resource:

HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://example.com/views/dashboard.json>; rel="view-descriptor"

{"revenue": 48200, "users": 1847, "orders": 312}

The client fetches https://example.com/views/dashboard.json to get the view descriptor. This approach:

  • Keeps the data payload completely clean
  • Works with any data format (JSON, XML, OData4, GraphQL, Protocol Buffers)
  • The view descriptor resource is independently cacheable
  • Uses existing web standards (RFC 8288 Link Relations)

For simple cases (single template, no composition), a shorthand header is also defined:

View-Template: https://example.com/templates/article.html

When View-Template is present, it is equivalent to {"template": "<URL>"}. If both Link (with rel="view-descriptor") and View-Template are present, the Link header takes precedence.

4.2 Inline in Response Body

When the data format is flexible (e.g., HAL+JSON, custom APIs), embed the view descriptor directly using the _view key:

{
  "_links": {
    "self": { "href": "/api/dashboard" }
  },
  "_view": {
    "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/demos/dashboard.html",
    "slots": {
      "statsRow": {
        "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
      }
    }
  },
  "revenue": 48200,
  "users": 1847,
  "orders": 312
}

The _view key follows HAL's underscore convention for protocol-level metadata.

For multiple views, use _views:

{
  "_views": {
    "default": {
      "template": "https://example.com/templates/dashboard-full.html",
      "slots": { ... }
    },
    "widget": {
      "template": "https://example.com/templates/dashboard-widget.html"
    }
  },
  "revenue": 48200,
  "users": 1847
}

4.3 OData4 Compatibility

OData4 responses have a rigid structure but support custom instance annotations. Use an annotation to reference a view descriptor resource:

{
  "@odata.context": "https://example.com/odata/$metadata#Products",
  "@View.descriptor": "https://example.com/views/product-list.json",
  "value": [
    { "ProductID": 1, "Name": "Widget", "Price": 9.99 },
    { "ProductID": 2, "Name": "Gadget", "Price": 24.99 }
  ]
}

Alternatively, use the Link header approach (Section 4.1) to avoid touching the OData body entirely.

4.4 Precedence

When a view descriptor is provided via multiple mechanisms, precedence is:

  1. Inline body (_view / _views) — most specific
  2. Link header with rel="view-descriptor"
  3. View-Template header

5. View Descriptor Resources

5.1 Media Type

View descriptor resources SHOULD be served with:

Content-Type: application/vdp+json

5.2 Caching

View descriptor resources are independently cacheable. Servers SHOULD provide standard HTTP caching headers:

HTTP/1.1 200 OK
Content-Type: application/vdp+json
Cache-Control: public, max-age=3600
ETag: "v2-dashboard"

{
  "template": "https://example.com/templates/dashboard.html",
  "slots": { ... }
}

Template URLs themselves are also cacheable resources. Clients SHOULD cache resolved templates according to their HTTP caching headers.

5.3 Versioning

View descriptors can be versioned by URL convention:

https://example.com/views/v2/dashboard.json
https://example.com/views/dashboard.json?v=2

Or by content negotiation using the Accept header with a version parameter:

Accept: application/vdp+json; version=2

6. Template Requirements

VDP is agnostic to the template language. However, templates used with VDP MUST satisfy one requirement: named insertion points (slots) that can be filled externally.

6.1 Framework Slot Mappings

Framework Slot Mechanism Example
Qute {#insert slotName}{/insert} {#insert mainContent}Default{/insert}
HTML <template> <slot name="slotName"> <slot name="mainContent"></slot>
HTMT ht-template="slotName" <div ht-template="mainContent"></div>
Thymeleaf th:fragment / th:replace <div th:replace="~{slotName}"></div>
JSX/React props.children or named props {props.mainContent}
SwiftUI @ViewBuilder parameters var mainContent: () -> Content
Jetpack Compose @Composable slot parameters mainContent: @Composable () -> Unit

6.2 Static vs Dynamic Slots

Not all insertion points in a template need to be managed by VDP. Templates commonly include static partials (like a shared _head.html or a footer) that are hardcoded. Only slots that vary per API response need to appear in the view descriptor.

7. Examples

7.1 Login Page (Simple, No Slots)

API Response:

HTTP/1.1 200 OK
Content-Type: application/json
View-Template: https://github.com/SiteNetSoft/quarkus-pha/templates/components/forms/form.html

{
  "csrfToken": "abc123",
  "loginUrl": "/auth/login",
  "fields": [
    { "name": "username", "type": "text", "label": "Username", "required": true },
    { "name": "password", "type": "password", "label": "Password", "required": true }
  ]
}

7.2 Dashboard (Composed Template Tree)

API Response:

HTTP/1.1 200 OK
Content-Type: application/hal+json
Link: <https://github.com/SiteNetSoft/quarkus-pha/views/dashboard.json>; rel="view-descriptor"

{
  "_links": { "self": { "href": "/api/dashboard" } },
  "stats": { "revenue": 48200, "users": 1847, "orders": 312 },
  "recentActivity": [
    { "user": "alice", "action": "purchase", "item": "Widget Pro", "time": "2m ago" },
    { "user": "bob", "action": "signup", "time": "15m ago" }
  ],
  "chartData": { "labels": ["Mon","Tue","Wed","Thu","Fri"], "values": [12,19,3,5,2] }
}

View Descriptor Resource (dashboard.json):

{
  "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/layouts/sidebar.html",
  "slots": {
    "sidebarNav": {
      "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/navigation/nav.html"
    },
    "mainContent": {
      "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/demos/dashboard.html",
      "slots": {
        "statsCards": {
          "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/card.html"
        },
        "activityTable": {
          "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/data-display/table.html"
        },
        "revenueChart": {
          "template": "https://github.com/SiteNetSoft/quarkus-pha/templates/components/charts/chart.html"
        }
      }
    }
  }
}

7.3 OData4 Product List

HTTP/1.1 200 OK
Content-Type: application/json;odata.metadata=minimal
Link: <https://example.com/views/product-list.json>; rel="view-descriptor"

{
  "@odata.context": "https://example.com/odata/$metadata#Products",
  "value": [
    { "ProductID": 1, "Name": "Widget", "Price": 9.99 },
    { "ProductID": 2, "Name": "Gadget", "Price": 24.99 }
  ]
}

Data payload is pure OData4. The view descriptor is communicated entirely via the Link header.

7.4 Multiple Views (Responsive)

{
  "_views": {
    "default": {
      "template": "https://example.com/templates/product-detail.html",
      "slots": {
        "gallery": {
          "template": "https://example.com/templates/components/image-carousel.html"
        },
        "reviews": {
          "template": "https://example.com/templates/components/review-list.html"
        }
      }
    },
    "compact": {
      "template": "https://example.com/templates/product-card.html"
    }
  },
  "id": 42,
  "name": "Widget Pro",
  "price": 29.99,
  "images": ["front.jpg", "side.jpg", "back.jpg"],
  "reviews": [
    { "author": "Alice", "rating": 5, "text": "Excellent!" }
  ]
}

7.5 BFF (Backend for Frontend) Pattern

A BFF receives an API response and a view descriptor. Instead of forwarding both to the browser, the BFF resolves the template tree server-side and returns rendered HTML:

Browser → GET /dashboard
BFF → GET /api/dashboard (receives data + Link header with view descriptor)
BFF → Fetches view descriptor
BFF → Fetches templates (with caching)
BFF → Renders composed template tree with data (using Qute, Thymeleaf, etc.)
BFF → Returns rendered HTML to browser

This is the pattern used by quarkus-pha: Quarkus acts as the BFF, fetching data and resolving Qute templates server-side.

8. Client Resolution Algorithm

  1. Extract view descriptor from the response (check _view/_views body key, then Link header, then View-Template header).
  2. Fetch the view descriptor if it is a URL reference (cache as appropriate).
  3. Fetch the root template from the template URL.
  4. Identify slot insertion points in the template.
  5. For each slot declared in the view descriptor: a. Fetch the sub-template from its template URL. b. If the sub-template's view descriptor has slots, recurse (go to step 4). c. Insert the resolved sub-template into the slot.
  6. Render the composed template tree with the API response data.

Clients SHOULD impose a maximum recursion depth (RECOMMENDED: 10 levels) to prevent unbounded nesting.

9. Security Considerations

  • Template URL validation: Clients MUST validate template URLs against an allowlist of trusted domains. Rendering arbitrary templates from untrusted sources is a code injection risk.
  • CORS: Template resources served cross-origin MUST include appropriate CORS headers.
  • Content Security Policy: Template URLs SHOULD be included in the script-src or style-src CSP directives as appropriate.
  • Template sandboxing: Clients SHOULD render templates in a sandboxed context to prevent template injection attacks.
  • HTTPS: Template URLs MUST use HTTPS in production. Clients SHOULD reject HTTP template URLs.

10. Relationship to Existing Standards

Standard Relationship
REST VDP extends REST responses with view metadata without modifying the resource representation itself
HAL (RFC draft) VDP uses HAL's underscore convention (_view) for inline transport. Compatible with _links and _embedded
JSON-LD VDP can coexist with @context/@type annotations. Template URLs could be expressed as JSON-LD @id values
OData4 VDP uses OData4 instance annotations (@View.descriptor) or HTTP headers for compatibility
RFC 8288 (Web Linking) VDP defines the view-descriptor link relation type for the Link header
HATEOAS VDP is complementary — HATEOAS tells clients what actions are available, VDP tells clients how to render the result

11. IANA Considerations

This specification requests registration of:

11.1 Link Relation Type

  • Relation Name: view-descriptor
  • Description: Refers to a VDP view descriptor resource that describes how to render the linked resource.
  • Reference: This specification

11.2 Media Type

  • Type name: application
  • Subtype name: vdp+json
  • Required parameters: None
  • Optional parameters: version
  • Reference: This specification

12. Discovery

APIs SHOULD advertise VDP support so clients can detect it programmatically.

12.1 OPTIONS Response

An API endpoint supporting VDP MUST include the VDP token in the Allow or a custom header in its OPTIONS response:

OPTIONS /api/dashboard HTTP/1.1

HTTP/1.1 204 No Content
Allow: GET, HEAD, OPTIONS
VDP-Support: true
VDP-Version: 0.1

12.2 Well-Known URI

APIs MAY expose a discovery document at /.well-known/vdp:

GET /.well-known/vdp HTTP/1.1

HTTP/1.1 200 OK
Content-Type: application/vdp+json

{
  "version": "0.1",
  "endpoints": {
    "/api/dashboard": {
      "template": "https://example.com/views/dashboard.json"
    },
    "/api/products": {
      "template": "https://example.com/views/product-list.json"
    }
  },
  "trustedTemplateDomains": [
    "https://github.com/SiteNetSoft/quarkus-pha"
  ]
}

This allows clients to prefetch view descriptors and preload templates before making data requests. The trustedTemplateDomains field provides the template URL allowlist referenced in Section 9.

12.3 OpenAPI Extension

For APIs documented with OpenAPI 3.x, VDP metadata can be declared using the x-vdp extension:

paths:
  /api/dashboard:
    get:
      summary: Get dashboard data
      x-vdp:
        view-descriptor: "https://example.com/views/dashboard.json"
      responses:
        '200':
          description: Dashboard data
          headers:
            Link:
              description: View descriptor reference
              schema:
                type: string

Design Decisions

The following questions were considered and resolved during the design of this specification:

  1. Conditional slots (e.g., "use template A for admins, B for guests"): Not in scope. Authorization logic belongs on the server. The server sends different view descriptors based on the user's role. VDP is purely declarative — it describes what to render, not when or for whom.

  2. Template parameters (e.g., passing {"compact": true} to a template): Not in scope. VDP declares which templates to use, nothing more. Configuration, styling, and data binding are the template engine's responsibility. Keeping VDP minimal ensures it works across all rendering frameworks without making assumptions about their capabilities.

  3. Data-to-template mapping (e.g., specifying which JSON fields feed which template): Not in scope. Templates are responsible for extracting data from the API response using their own mechanisms (Qute expressions, JSONPath, data attributes, etc.). VDP maintains a clean separation between template selection and data binding.

@jnbdz

jnbdz commented Feb 27, 2026

Copy link
Copy Markdown
Author

Gaps in the spec itself:

  • Error handling — What should a client do when a template URL 404s? When a slot name in the descriptor doesn't match any insertion point in the template? No fallback behavior is defined.
  • Relative template URLs — The spec only shows absolute URLs. Should relative URLs be allowed (resolved against a base URL)? That would make view descriptors more portable.
  • Partial updates — HTMX does partial page swaps. Can VDP describe "just re-render this one slot"? That's a real use case with your quarkus-pha stack.

Connections to your other projects:

  • HTMT — It defines ht-template as a slot mechanism. Is HTMT essentially the client-side implementation of VDP for HTML? That relationship should be documented.
  • quarkus-pha — How would it actually consume VDP? A Quarkus filter/interceptor that reads _view from upstream API responses and resolves the Qute template tree? That could be your reference implementation.
  • RVST repo — VDP replaces RVST. What happens to that repo? Rename it? Archive it?

Not missing but could come later (v0.2+):

  • GraphQL-specific example
  • WebSocket/SSE for real-time view descriptor updates
  • A formal ABNF grammar for the View-Template header

My honest assessment: What you have is a publishable v0.1 working draft. The spec does one thing well and stays out of everything else. The biggest value-add from here would be a reference implementation in quarkus-pha — that would make the spec concrete and expose any design issues faster than more spec writing
would.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment