Extending the API

Delegations (REST → GraphQL)

Delegations #

Delegations expose an external REST API as GraphQL fields on your Husaria schema. Point Husaria at a source's OpenAPI 3 specification, tick the endpoints you want to import, and they become delegated_<source>_<operation> fields under Query (for GET / HEAD) or Mutation (for POST / PUT / PATCH / DELETE). When a client calls one of those fields, Husaria translates the GraphQL arguments back into a REST request, applies the configured upstream auth, and forwards the response.

This sits next to Actions on the customisation surface: actions wrap a single webhook per registration, delegations wrap an entire REST API per source.

When to use a delegation #

  • You want to expose a third-party API (weather, payments, geocoding) through your GraphQL endpoint.
  • You want server-side credential management — clients call Husaria with their normal auth and never see the upstream token.
  • The upstream service publishes an OpenAPI 3 spec.
  • You want simple per-source response caching without writing custom resolvers.

For pure database reads — even complex ones — direct GraphQL against your tables remains faster. Delegations are for bridging to external systems.

Not sure which surface fits? See the side-by-side comparison of Actions vs Delegations vs Events on the introduction page.

Field naming #

Every imported endpoint lands as delegated_<source>_<operation>, normalised:

  • Lowercased.
  • Non-alphanumeric characters collapsed to underscore.
  • Deduplicated when two operationIds collide (a numeric suffix is appended).

So a source named weatherapi with an OpenAPI operationId getCurrentByCity becomes delegated_weatherapi_get_current_by_city. If the spec omits an operationId, the suggested name is derived from the HTTP method + path.

The GraphQL root is chosen per HTTP method:

HTTP methodGraphQL root
GET, HEADQuery
POST, PUT, PATCH, DELETEMutation

Other methods (OPTIONS, CONNECT, TRACE) are skipped.

Lifecycle #

Step 1. Register a source #

Either through the console Delegations tab or via POST /api/delegations. Required fields:

  • name — a friendly identifier (weatherapi, stripe, …). Used in the field-name prefix.
  • base_url — the upstream service's base URL.
  • auth_type — one of none, bearer, basic, api_key_header, api_key_query, custom_headers.
  • auth_config — polymorphic per auth_type, see Authentication options below.

Optional:

  • openapi_url — where Husaria should fetch the OpenAPI 3 spec from when you click Refresh.
  • comment — free-form description; surfaced in the panel.

Step 2. Refresh the spec #

Either paste a spec body into the Refresh dialog, or let Husaria fetch from openapi_url. The spec is cached on the row (openapi_spec, openapi_loaded_at) so re-imports don't hit the upstream every time.

Husaria's in-package parser handles the subset of OpenAPI 3 you actually need: paths, parameters (path / query / header), requestBody, and the 2xx response schema. Body size cap on fetch is 16 MiB.

Step 3. Discover #

GET /api/delegations/:id/discover returns a flat list of every path × method pair in the cached spec, each with a suggested GraphQL field name, summary, tags, and the parameter/body/response schemas. The console Discover dialog renders this as a checklist with method-coloured pills.

Step 4. Import #

POST /api/delegations/:id/import with the ticked entries upserts them into husaria_data.delegation_endpoints. Each row carries the parameters, body schema, response schema, the field name, and an optional per-endpoint cache TTL. Imports are idempotent on (delegation_id, http_method, path).

Each selected endpoint defaults to its suggested field name, but you can override the GraphQL field name per endpoint in the Import dialog (it must be a valid identifier). Send field_name per entry to set it; leave it blank and the server derives delegated_<source>_<operation> itself.

After a successful import the console auto-fires the schema reloader so the new delegated_* fields appear in GraphiQL without a second click. Programmatic callers can hit POST /console/api/schema/reload themselves to achieve the same effect.

Step 5. Grant role access #

Delegations are RBAC-gated at the source level — granting a role on a delegation row exposes every imported endpoint under that source. CRUD lives at /api/delegations/:id/permissions. The admin role bypasses (parity with Actions).

When a non-admin caller hits a delegated_* field, the resolver checks husaria_data.delegation_permissions for a (delegation_id, role) match. Empty role is rejected — a caller without an explicit role gets a forbidden GraphQL error. There is no implicit "trust caller" fallback any more.

Step 6. Call it #

GraphQL clients see the new fields immediately:

query Forecast {
  delegated_weatherapi_get_current_by_city(city: "Warsaw", units: "metric") {
    # response shape inferred from the OpenAPI response schema
  }
}

The resolver:

  1. Interpolates path parameters ({cityId} → the GraphQL cityId argument).
  2. Buckets query / header parameters from their declared in field.
  3. Folds the input argument (typed as the auxiliary DelegatedJSON scalar) into the request body for write methods.
  4. Resolves ${ENV_NAME} placeholders in auth config from the runtime env (see Secret indirection).
  5. Adds the source's configured auth header.
  6. Issues the HTTP request with a 60-second default upstream timeout.
  7. Decodes JSON (application/json family) or returns text otherwise.

Authentication options #

auth_typeauth_config shapeWire effect
noneNo auth header added.
bearer{token: "..."} or {token_env: "ENV_NAME"}Authorization: Bearer <token>
basic{username: "...", password: "..."}Authorization: Basic <base64(user:pass)>
api_key_header{header_name: "X-API-Key", value: "..." } (or value_env)Custom request header set to the key
api_key_query{param_name: "api_key", value: "..." } (or value_env)Appended as a query parameter
custom_headers{headers: {"X-A": "1", "X-B": "2"}}Each header set on the outbound request

Validation lives in the Go layer (Delegation.Validate()) and mirrors a CHECK constraint on husaria_data.delegations.auth_type — invalid combinations are rejected at the API with a 400.

Secret indirection #

Storing secrets verbatim in husaria_data.delegations.auth_config works for development but tends to fail prod audit. Two indirection mechanisms exist; prefer the *_env fields:

  • *_env fields (recommended). Set token_env, value_env, or password_env to the name of an environment variable. The resolver reads that variable at call time. Any env-var name is accepted here — wire it to whatever secret store your cluster speaks (Kubernetes Secret, sealed-secrets, Vault Agent Injector, etc.).
  • Inline ${VAR} interpolation (restricted). Inside literal token / value / headers values, ${VAR} is expanded — but only when VAR matches HUSARIA_DELEGATION_*. This stops an admin (or anyone who compromised the admin secret) from staging ${AWS_SECRET_ACCESS_KEY} in a delegation config and exfiltrating cluster secrets to an attacker-controlled upstream.

For example: {"token_env": "MY_RANDOM_TOKEN"} works; {"token": "${MY_RANDOM_TOKEN}"} does not expand. {"token": "${HUSARIA_DELEGATION_WEATHER_KEY}"} expands as expected.

Snapshots of husaria_data.delegations carry only env-var names when you use *_env; no live credentials at rest.

HTTP API #

All routes mount under /api/delegations, gated by the same admin-secret middleware as /api/actions. Read-only replicas reject writes (see Read-only replicas).

Source CRUD #

MethodPathPurpose
POST/api/delegationsCreate a source.
GET/api/delegationsList sources.
GET/api/delegations/:idRead one.
PUT/api/delegations/:idUpdate.
DELETE/api/delegations/:idDelete (cascades to endpoints + permissions).

Spec + discovery #

MethodPathPurpose
POST/api/delegations/:id/refreshRe-cache the spec. Body may carry a pasted spec, or Husaria fetches openapi_url.
GET/api/delegations/:id/discoverFlat list of endpoints from the cached spec, with suggested field names.
POST/api/delegations/:id/importUpsert ticked endpoints into delegation_endpoints.

Endpoint catalog #

MethodPathPurpose
GET/api/delegations/:id/endpointsList imported endpoints for this source.
PUT/api/delegations/:id/endpoints/:endpointIdUpdate an endpoint (toggle enabled, change cache_ttl_sec).
DELETE/api/delegations/:id/endpoints/:endpointIdRemove a single endpoint.

Permissions #

MethodPathPurpose
GET/api/delegations/:id/permissionsList role grants on this source.
POST/api/delegations/:id/permissionsGrant a role.
DELETE/api/delegations/:id/permissions/:roleRevoke a role.

Response cache #

Each endpoint has an optional cache_ttl_sec. When set, the resolver keys responses on (endpoint_id, normalised args) and returns the cached payload until TTL elapses. This is a per-process cache — independent across replicas, no shared invalidation. Set TTL to 0 (the default) to disable.

For globally-shared caching, lean on the response cache layer instead — it sits in front of the GraphQL handler and applies uniformly to all fields including delegated ones.

Storage #

Three catalog tables under husaria_data:

  • delegations — one row per source. Columns: id, name, base_url, openapi_url, openapi_spec (JSON), openapi_loaded_at, auth_type, auth_config (JSON), comment, timestamps. CHECK constraint on auth_type mirrors the Go enum.
  • delegation_endpoints — one row per imported endpoint. Columns: id, delegation_id, http_method, path, operation_id, field_name, summary, graphql_type, enabled, parameters (JSON), request_body (JSON), response_schema (JSON), cache_ttl_sec, timestamps.
  • delegation_permissions(delegation_id, role) rows. Source-scoped — granting a role on a delegation exposes every endpoint under it.

Security posture #

The delegation pipeline has gone through a dedicated hardening pass. The currently-enforced guards:

  • SSRF allowlist on outbound calls. Both spec fetch (/api/delegations/:id/refresh with openapi_url) and the resolver's REST call refuse to connect to loopback, RFC1918, link-local (including 169.254.169.254 cloud-metadata), or CGNAT addresses, and to non-http(s) schemes. The shared dialer enforces the gate even when callers forget to validate upfront. Redirect hops are re-checked so an upstream can't 302 you into the metadata service. Set HUSARIA_DELEGATION_ALLOW_PRIVATE_HOSTS=true to opt out — only safe on tightly-controlled internal networks.
  • Spec-parse DoS limits. ParseSpec rejects specs with more than 4096 paths or 256 parameters per operation. Larger specs are almost certainly hostile or accidentally generated; reject early.
  • /refresh body cap. Pasted spec body capped at 16 MiB via http.MaxBytesReader.
  • Delegation.Sanitized() redaction on list / get responses. Token, password, api-key value, and custom_headers value fields are blanked before serialisation. The Delegations panel + the /api/delegations* API surface no longer echo secrets to dev tools or to a logged-in operator's browser.
  • Query-string stripping in debug logs. api_key_query sources don't leak the key into structured log lines.
  • JWT / CSRF error responses tightened. Middleware no longer surfaces internal error strings (alg / kid / SameSite mismatches) — the classification alone goes to the client; full detail stays in the access log so an attacker can't probe which check failed.

What delegations are not #

  • Not a generic GraphQL gateway. No remote-schema federation, no GraphQL-to-GraphQL stitching. The remote shape is REST.
  • Not OAuth-aware. Husaria doesn't run the OAuth dance on behalf of the caller. Supply a pre-minted bearer token (via env var or rotated by your secret system) or use a non-OAuth auth type.
  • Not retry-aware. A failing upstream returns a GraphQL error; the resolver doesn't retry. Wrap your upstream calls with a circuit breaker in front of Husaria if you need that semantic.
  • Not request-shaping. Path / query / header / body params come straight from the OpenAPI spec. If the upstream's wire shape is awkward, write an Action that adapts it instead.