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 method | GraphQL root |
|---|---|
GET, HEAD | Query |
POST, PUT, PATCH, DELETE | Mutation |
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 ofnone,bearer,basic,api_key_header,api_key_query,custom_headers.auth_config— polymorphic perauth_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:
- Interpolates path parameters (
{cityId}→ the GraphQLcityIdargument). - Buckets query / header parameters from their declared
infield. - Folds the
inputargument (typed as the auxiliaryDelegatedJSONscalar) into the request body for write methods. - Resolves
${ENV_NAME}placeholders in auth config from the runtime env (see Secret indirection). - Adds the source's configured auth header.
- Issues the HTTP request with a 60-second default upstream timeout.
- Decodes JSON (
application/jsonfamily) or returns text otherwise.
Authentication options #
auth_type | auth_config shape | Wire effect |
|---|---|---|
none | — | No 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:
*_envfields (recommended). Settoken_env,value_env, orpassword_envto 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 literaltoken/value/headersvalues,${VAR}is expanded — but only whenVARmatchesHUSARIA_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 #
| Method | Path | Purpose |
|---|---|---|
POST | /api/delegations | Create a source. |
GET | /api/delegations | List sources. |
GET | /api/delegations/:id | Read one. |
PUT | /api/delegations/:id | Update. |
DELETE | /api/delegations/:id | Delete (cascades to endpoints + permissions). |
Spec + discovery #
| Method | Path | Purpose |
|---|---|---|
POST | /api/delegations/:id/refresh | Re-cache the spec. Body may carry a pasted spec, or Husaria fetches openapi_url. |
GET | /api/delegations/:id/discover | Flat list of endpoints from the cached spec, with suggested field names. |
POST | /api/delegations/:id/import | Upsert ticked endpoints into delegation_endpoints. |
Endpoint catalog #
| Method | Path | Purpose |
|---|---|---|
GET | /api/delegations/:id/endpoints | List imported endpoints for this source. |
PUT | /api/delegations/:id/endpoints/:endpointId | Update an endpoint (toggle enabled, change cache_ttl_sec). |
DELETE | /api/delegations/:id/endpoints/:endpointId | Remove a single endpoint. |
Permissions #
| Method | Path | Purpose |
|---|---|---|
GET | /api/delegations/:id/permissions | List role grants on this source. |
POST | /api/delegations/:id/permissions | Grant a role. |
DELETE | /api/delegations/:id/permissions/:role | Revoke 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 onauth_typemirrors 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/refreshwithopenapi_url) and the resolver's REST call refuse to connect to loopback, RFC1918, link-local (including169.254.169.254cloud-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. SetHUSARIA_DELEGATION_ALLOW_PRIVATE_HOSTS=trueto opt out — only safe on tightly-controlled internal networks. - Spec-parse DoS limits.
ParseSpecrejects specs with more than 4096 paths or 256 parameters per operation. Larger specs are almost certainly hostile or accidentally generated; reject early. /refreshbody cap. Pasted spec body capped at 16 MiB viahttp.MaxBytesReader.Delegation.Sanitized()redaction on list / get responses. Token, password, api-key value, andcustom_headersvalue 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_querysources 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.