Skip to main content

GraphQL API

The GraphQL API is the primary interface for querying and mutating data in hugr. It provides a standardized GraphQL endpoint that accepts queries in the standard GraphQL format over HTTP.

Overview

The GraphQL API endpoint provides:

  • Standard GraphQL Protocol: Full compliance with the GraphQL specification
  • Flexible Queries: Read operations with filtering, sorting, pagination, and relationships
  • Mutations: Create, update, and delete operations with transaction support
  • Introspection: Full schema introspection for tools and clients
  • Authentication: Integrated with hugr's authentication system (API keys, JWT, OIDC, anonymous)
  • Access Control: Role-based permissions applied automatically

Endpoint Details

Path: /query

Methods: GET, POST

Content-Type: application/json

Request Format

POST Requests (Recommended)

POST requests are the standard way to send GraphQL queries. They support all GraphQL features including variables and operation names.

Headers

Content-Type: application/json

Optional authentication:

Authorization: Bearer <token>

Optional timezone (applies to Timestamp/TIMESTAMPTZ values in results):

X-Hugr-Timezone: America/New_York

or use the GitHub-style fallback header:

Time-Zone: Europe/Moscow

When set, all TIMESTAMPTZ values in the response are displayed in the specified timezone. DateTime/TIMESTAMP values are not affected. If no timezone header is sent, the server default (DB_TIMEZONE) or system timezone (UTC) is used.

Request Body

{
"query": "<graphql_query>",
"variables": {
"var1": "value1",
"var2": "value2"
},
"operationName": "<operation_name>"
}

Fields:

  • query (string, required): GraphQL query or mutation text
  • variables (object, optional): Variables used in the query
  • operationName (string, optional): Name of the operation to execute (for documents with multiple operations)

Example

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token-here" \
-d '{
"query": "query GetUsers($limit: Int!) { users(limit: $limit) { id name email } }",
"variables": {
"limit": 10
},
"operationName": "GetUsers"
}'

GET Requests

GET requests support simple queries without variables. They are useful for bookmarking, caching, and simple integrations.

Query Parameters

  • query (required): URL-encoded GraphQL query
  • variables (optional): URL-encoded JSON object with variables
  • operationName (optional): Operation name

Example

curl -X GET "http://localhost:8080/query?query=%7B%20users%20%7B%20id%20name%20email%20%7D%20%7D"

With variables:

curl -X GET "http://localhost:8080/query?query=query%20GetUser(%24id%3A%20Int!)%20%7B%20users(filter%3A%20%7Bid%3A%20%7Beq%3A%20%24id%7D%7D)%20%7B%20id%20name%20%7D%20%7D&variables=%7B%22id%22%3A%201%7D"

Note: GET requests have URL length limitations. Use POST for complex queries.

Response Format

Success Response

HTTP Status: 200 OK

Body:

{
"data": {
"users": [
{"id": 1, "name": "Alice", "email": "alice@example.com"},
{"id": 2, "name": "Bob", "email": "bob@example.com"}
]
},
"extensions": {}
}

The response follows the standard GraphQL response format:

  • data: Query results (null if errors occurred)
  • extensions: Additional metadata (e.g., JQ transformation results, execution time)
  • errors (optional): Array of errors if any occurred

Partial Error Response

GraphQL can return partial data even when some fields have errors:

HTTP Status: 200 OK

Body:

{
"data": {
"users": [
{"id": 1, "name": "Alice", "email": null}
]
},
"errors": [
{
"message": "Field 'email' access denied",
"locations": [{"line": 1, "column": 20}],
"path": ["users", 0, "email"]
}
]
}

Error Response

When the query cannot be executed at all:

HTTP Status: 200 OK (GraphQL convention), 400 Bad Request, or 401 Unauthorized

Body:

{
"data": null,
"errors": [
{
"message": "Cannot query field 'invalid_field' on type 'User'",
"locations": [{"line": 1, "column": 15}]
}
]
}

HTTP Status Codes

CodeDescription
200 OKRequest processed (may contain GraphQL errors)
400 Bad RequestMalformed GraphQL query or invalid JSON
401 UnauthorizedMissing or invalid authentication
403 ForbiddenAccess denied by authorization rules
500 Internal Server ErrorServer-side error during query execution

Authentication

The GraphQL API integrates with hugr's authentication system. All authentication methods are supported:

API Key Authentication

Include the API key in the Authorization header:

curl -X POST http://localhost:8080/query \
-H "Authorization: Bearer service_key_abc123" \
-H "X-API-Username: api_service" \
-H "X-API-User-ID: svc_001" \
-d '{"query": "{ users { id name } }"}'

See Authentication Setup for API key configuration.

JWT/OIDC Authentication

Include the JWT token:

curl -X POST http://localhost:8080/query \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
-d '{"query": "{ users { id name } }"}'

For web applications, tokens can be passed via cookies:

curl -X POST http://localhost:8080/query \
-H "Cookie: hugr_session=eyJhbGciOiJIUzI1NiIs..." \
-d '{"query": "{ users { id name } }"}'

The cookie name is configured with the OIDC_COOKIE_NAME environment variable (default: hugr_session).

Anonymous Access

If anonymous access is enabled, requests without authentication are assigned the anonymous role:

curl -X POST http://localhost:8080/query \
-d '{"query": "{ public_data { id name } }"}'

Anonymous users only see data permitted for their role based on permissions configured in the role_permissions table. Fields with hidden: true are not visible in introspection but can be explicitly requested. Fields with disabled: true are completely inaccessible.

GraphQL Introspection

The GraphQL API supports full introspection, allowing tools and clients to discover the schema dynamically.

Full Schema Introspection

query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
types {
name
kind
description
fields {
name
description
type {
name
kind
}
}
}
}
}

Type Introspection

Query specific types:

query TypeIntrospection {
__type(name: "User") {
name
kind
description
fields {
name
type {
name
kind
ofType {
name
kind
}
}
}
}
}

Logical Model Introspection (_catalog)

Standard GraphQL introspection exposes the generated schema — hundreds of types including filters, aggregations, and mutation inputs. The _catalog meta-query family exposes hugr's logical data model directly: the module tree, data objects (tables and views) with their relations, and functions — without reverse-engineering generated type names.

These meta queries are available (resolved like __schema/__type, never executed as data queries):

QueryReturnsPurpose
_catalog_ModuleThe root module tree (name: "")
_module(name: String!)_ModuleDirect module lookup; "" = root
_dataObject(name: String!)_DataObjectData object by GraphQL type name
_function(module: String!, name: String!)_FunctionFunction/mutation/subscription lookup; module: "" = root
_dataSources[_DataSource!]Attached data sources contributing anything visible to the caller
_dataSource(name: String!)_DataSourceData source lookup by name
_types(scope: _TypeScope = SOURCE)[__Type!]Logical-model type definitions: SOURCE — residual base types defined by data sources (structs, inputs, enums; excludes data objects, module roots and generated helper types); SYSTEM — engine-defined types
_search(query: String!, …)_SearchResultRank the logical model by relevance to a natural-language description — see Searching the model

Unknown names resolve to null (never an error).

Explore the whole model as a tree:

{
_catalog {
name # "" — the root module
dataSources
modules {
name
dataObjects { name type description }
functions { name type isTable } # type: FUNCTION | MUTATION | SUBSCRIPTION
modules { name } # nested submodules
}
dataObjects { name type } # root-level objects (no @module)
}
}

Inspect one data object — properties, keys, arguments, relations:

{
_dataObject(name: "orders") {
type # TABLE | VIEW
properties { isCube isM2M isHypertable softDelete hasVectors }
primaryKey # @pk field names
args { name type { name } } # parameterized-view arguments
dataSourceName # owning data source
dataSources # owner + sources extending this object
relations {
name
direction # FORWARD | BACK
kind # FK | M2M | JOIN
fieldName # the field materializing the edge
dataObject { name } # the far object
through { name } # M2M junction (null otherwise)
sourceKeys
destinationKeys
}
}
}

Relations show the logical link graph from both ends: FORWARD/FK for the object's own references, BACK/FK for objects referencing it, M2M with the junction in through, and one-directional JOIN edges for @join fields.

Module and function lookups:

{
mod: _module(name: "core.cache") {
functions { name type }
subscriptionType { name } # per-module generated root types
}
fn: _function(module: "core", name: "load_data_source") {
type isTable args { name } returns { name }
}
}

These are meta-fields, in the same sense __schema and __typename are: resolved on the metadata path, never planned as data queries, and not members of the served schema. Like __schema, they are therefore not listed in Query.fields, and their result types are not listed in __schema.types — so a GraphQL IDE will not autocomplete them and a code generator will not emit them. They remain fully callable, and __type(name: "_Module") still describes the meta-types by name, which is how a client probes for the capability:

{ __type(name: "_SearchResult") { name } } # null on an engine without _search

GraphQL reserves double-underscore names for its own introspection system, which is why the family uses a single underscore.

Being meta-fields also puts them outside the role permission rules. Nobody writes a permission row for __typename, and the same applies here: a deployment that locks down with a wildcard rule (type_name: "*", field_name: "*") and grants back explicitly does not lose logical-model introspection — nor standard __schema introspection, which is subject to the same rule. A disabled role is still refused everywhere.

What the meta queries return is filtered per role exactly as __schema is (see below): hidden objects disappear from every path — including other objects' relations — while disabled ones stay visible; modules left with no visible content are omitted from modules listings. Only the entry point is exempt from the rules, never the content.

_catalog answers what exists. _search answers what is relevant: give it a description in your own words and it ranks modules, data sources, data objects, functions and fields by meaning.

{
_search(query: "customer orders with payment status", kinds: [DATA_OBJECT, FIELD], limit: 20) {
lexical
lexicalReason
hasMore
filteredOut
items {
kind # MODULE | DATA_SOURCE | DATA_OBJECT | FUNCTION | FIELD
matchedOn # NAME | MEANING — which track found it
name
moduleName # where to nest the query
dataSourceName
description
score # 0..1, higher is better

# FIELD hits
objectName # the data object the field belongs to
type # the field's GraphQL type — "String!", "[Int]"
hugrType # what it IS: column | calculated | function | select
refObjectName # for a declared @join: the object it navigates to

# drill down through the ordinary _catalog resolvers, in the same round trip
dataObject { name type primaryKey queries { name type } }
}
}
}
ArgumentTypeDefaultPurpose
queryString!Natural-language description of what you are looking for
kinds[_SearchKind!]allMODULE, DATA_SOURCE, DATA_OBJECT, FUNCTION, FIELD
match_SearchMatchBOTHWhat to match on: NAME, MEANING, or BOTH — see below
moduleString"" (all)Restrict to this module's subtree. A field hit is scoped by the module of the object that owns it. Data sources are not module-scoped — a source contributes to several
objectStringRestrict FIELD hits to one data object
limit / offsetInt50 / 0Page size (1–200) and hits to skip
minScoreFloatDrop MEANING hits below this score. Name-track hits are never thresholded — a bar tuned for semantic similarity must not delete the identifier you typed
includeMcpExcludedBooleantrueInclude fields marked @exclude_mcp — an AI-tooling policy, not an access rule

Name and meaning are different questions. The vector index is built from descriptions, so an identifier never enters it: ranking aw_Product by meaning finds whatever is described in similar words, not the table you named. match selects the track:

  • NAME — substring matching over the entity's name. Always available, needs no embedder, and the only way to find an identifier. An exact name scores 1.
  • MEANING — semantic ranking over descriptions, degrading to substring matching when there is no vector index.
  • BOTH (default) — name matches first, then meaning, deduplicated. Each hit carries matchedOn, and scores are comparable within a track only: an exact identifier and an embedding distance are not on one scale, which is why the two are concatenated rather than blended — and why minScore binds only the MEANING track.

MCP's catalog-search pins MEANING: an agent describes the data it wants in its own words.

Ranking degrades, it does not disappear. With an embedder configured the ranking is semantic. Without one it falls back to substring matching and says so: lexical: true, and lexicalReason names the cause — a silent fallback would be indistinguishable from a broken ranking query. Lexical scoring requires every word of the query to appear somewhere, so a multi-word query narrows rather than widens; prefer exact terms when lexical is set.

There is no total. The permission filter runs after ranking, so an honest total would mean scanning the whole index on every keystroke. Page with hasMore. filteredOut counts candidates dropped because the caller may not see them — non-zero distinguishes "nothing matches" from "nothing you may see matches".

What a FIELD hit can be. Only four hugrType values reach a hit: column (a stored value), calculated (@sql), function (@function_call or a table-function join) and select (a declared @join, whose refObjectName names where it leads). Relation navigation fields and @extra_field companions (_<f>_part, _<f>_measurement) are generated when the GraphQL type is built rather than stored, so they are never search hits — reach them through _dataObject.

Role-Based Schema Visibility

Introspection results respect access control rules defined in the role_permissions table. The two flags are independent: hidden controls introspection visibility, disabled controls query access:

  • Fields with hidden: false (default): Visible in schema and queries
  • Fields with hidden: true: Not shown in introspection, but can still be explicitly requested
  • Fields with disabled: true: Querying returns a forbidden error; the field stays visible in introspection unless also hidden: true

To make a field both inaccessible and invisible, set both flags: hidden: true, disabled: true.

Each role sees only the types and fields permitted by their permissions. If a type/field has no permission entry for a role, it is accessible by default (unless restricted by a wildcard permission).

Examples

Simple Query

Fetch users:

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"query": "{ users { id name email } }"
}'

Query with Filtering

Filter by condition:

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"query": "{ users(filter: { status: { eq: \"active\" } }) { id name email } }"
}'

Query with Variables

Use variables for dynamic queries:

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"query": "query GetUser($userId: Int!) { users(filter: { id: { eq: $userId } }) { id name email } }",
"variables": {
"userId": 123
}
}'

Query with Relationships

Fetch related data:

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"query": "{ users { id name orders { id total created_at } } }"
}'

Mutation

Create a new record:

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{
"query": "mutation CreateUser($data: UsersInput!) { users { insert(data: $data) { id name email } } }",
"variables": {
"data": {
"name": "John Doe",
"email": "john@example.com",
"status": "active"
}
}
}'

Batch Mutation

Insert multiple records:

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{
"query": "mutation CreateUsers($users: [UsersInput!]!) { users { insert_batch(data: $users) { id name } } }",
"variables": {
"users": [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"}
]
}
}'

Aggregation Query

Calculate statistics:

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"query": "{ users_aggregation { _rows_count created_at { min max } } }"
}'

Spatial Query

Query geospatial data:

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"query": "{ locations(filter: { geometry: { st_within: { type: \"Polygon\", coordinates: [[[0,0],[10,0],[10,10],[0,10],[0,0]]] } } }) { id name geometry } }"
}'

CORS Configuration

For web applications making requests from browsers, configure CORS:

CORS_ALLOWED_ORIGINS=http://localhost:3000,https://app.example.com
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
CORS_ALLOWED_HEADERS=Content-Type,Authorization

See Configuration for details.

Performance Considerations

1. Query Complexity

Control query depth to prevent excessive nesting:

MAX_DEPTH=7 # Default maximum query depth

2. Parallel Execution

Enable parallel query execution for better performance:

ALLOW_PARALLEL=true
MAX_PARALLEL_QUERIES=10 # 0 for unlimited

3. Connection Pooling

Configure database connection pools:

DB_MAX_OPEN_CONNS=10
DB_MAX_IDLE_CONNS=5
DB_PG_CONNECTION_LIMIT=64 # For PostgreSQL sources

4. Caching

Use GraphQL directives or HTTP caching:

query GetStaticData {
reference_data @cache(ttl: 3600) {
id
name
}
}

See Cache Directives for more details.

Best Practices

1. Use POST for Complex Queries

GET requests have URL length limitations. Always use POST for:

  • Queries with variables
  • Mutations
  • Complex nested queries
  • Queries with large filter conditions

2. Leverage Variables

Use variables instead of string interpolation:

# Good: Use variables
query GetUser($id: Int!) {
users(filter: { id: { eq: $id } }) {
id name
}
}

# Avoid: String interpolation (security risk)
query {
users(filter: { id: { eq: 123 } }) {
id name
}
}

3. Request Only Needed Fields

Avoid over-fetching:

# Good: Specific fields
{ users { id name } }

# Avoid: Fetching unnecessary data
{ users { id name email phone address city country created_at updated_at } }

4. Use Filtering at Database Level

Apply filters in GraphQL, not in application code:

# Good: Filter in GraphQL
{ users(filter: { status: { eq: "active" } }) { id name } }

# Avoid: Fetch all and filter in code
{ users { id name status } }

5. Handle Errors Gracefully

Always check for errors in the response:

const response = await fetch('/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: '...' })
});

const result = await response.json();

if (result.errors) {
console.error('GraphQL errors:', result.errors);
// Handle errors
}

if (result.data) {
// Use data
}

6. Use Operation Names

Name your queries for better debugging:

query GetUserOrders($userId: Int!) {
users(filter: { id: { eq: $userId } }) {
id
name
orders {
id
total
}
}
}

7. Enable Persistent Queries

For production, consider using persisted queries to:

  • Reduce payload size
  • Improve security (only allow pre-approved queries)
  • Enable better caching

Security Considerations

1. Always Use HTTPS in Production

Encrypt all traffic:

server {
listen 443 ssl;
server_name api.example.com;

location /query {
proxy_pass http://hugr:8080;
}
}

2. Implement Rate Limiting

Prevent abuse with rate limiting:

limit_req_zone $binary_remote_addr zone=graphql:10m rate=10r/s;

location /query {
limit_req zone=graphql burst=20;
proxy_pass http://hugr:8080;
}

3. Validate Input

Use GraphQL variables and types to validate input:

query GetUser($id: Int!) { # Type validation
users(filter: { id: { eq: $id } }) {
id
name
}
}

4. Configure Access Control

Use role-based permissions to restrict access. Permissions are managed through the role_permissions table:

mutation {
core {
# Hide email field for viewer role
insert_role_permissions(data: {
role: "viewer"
type_name: "users"
field_name: "email"
hidden: true
}) {
role
type_name
field_name
}

# Disable password field for all non-admin roles
insert_role_permissions(data: {
role: "viewer"
type_name: "users"
field_name: "password"
disabled: true
}) {
role
type_name
field_name
}
}
}

See Access Control for details.

5. Monitor Query Complexity

Enable query depth limits:

MAX_DEPTH=7

6. Sanitize Sensitive Data

Remove sensitive fields from responses using permissions or transformations.

Troubleshooting

Connection Refused

Error: Connection refused or ECONNREFUSED

Solutions:

  1. Check hugr is running: curl http://localhost:8080/query
  2. Verify the port: Check BIND environment variable
  3. Check network configuration

Authentication Failed

Error: 401 Unauthorized

Solutions:

  1. Verify token is not expired
  2. Check Authorization header format: Bearer <token>
  3. Confirm authentication is configured correctly
  4. Test with anonymous access (if enabled)

Permission Denied

Error: 403 Forbidden or field returns null

Solutions:

  1. Check user role: Verify JWT claims or API key role
  2. Review access control rules in role_permissions table
  3. Check hidden and disabled flags on permissions for the role
  4. Test with admin role to isolate permission issues

Invalid Query Syntax

Error: Cannot query field 'X' on type 'Y'

Solutions:

  1. Use introspection to check available fields
  2. Verify field names match schema
  3. Check for typos in query

Query Timeout

Error: Request times out

Solutions:

  1. Add limit to reduce result size
  2. Optimize filters and indexes
  3. Check database performance
  4. Consider pagination for large datasets

CORS Errors (Browser)

Error: CORS policy: No 'Access-Control-Allow-Origin' header

Solutions:

  1. Configure CORS environment variables
  2. Check CORS_ALLOWED_ORIGINS includes your domain
  3. Verify CORS_ALLOWED_METHODS includes POST
  4. Ensure CORS_ALLOWED_HEADERS includes Content-Type and Authorization

GraphQL Clients

The GraphQL API works with any standard GraphQL client:

JavaScript/TypeScript

Apollo Client:

import { ApolloClient, InMemoryCache, gql } from '@apollo/client';

const client = new ApolloClient({
uri: 'http://localhost:8080/query',
cache: new InMemoryCache(),
headers: {
authorization: 'Bearer your-token'
}
});

const { data } = await client.query({
query: gql`{ users { id name } }`
});

urql:

import { createClient } from 'urql';

const client = createClient({
url: 'http://localhost:8080/query',
fetchOptions: {
headers: {
authorization: 'Bearer your-token'
}
}
});

graphql-request:

import { GraphQLClient } from 'graphql-request';

const client = new GraphQLClient('http://localhost:8080/query', {
headers: {
authorization: 'Bearer your-token'
}
});

const data = await client.request(`{ users { id name } }`);

Python

hugr-client (Recommended):

from hugr_client import HugrClient

client = HugrClient(
url='http://localhost:8080',
token='your-token'
)

df = client.query('{ users { id name email } }')

See Python Client for more details.

gql:

from gql import gql, Client
from gql.transport.requests import RequestsHTTPTransport

transport = RequestsHTTPTransport(
url='http://localhost:8080/query',
headers={'authorization': 'Bearer your-token'}
)

client = Client(transport=transport)

query = gql('{ users { id name } }')
result = client.execute(query)

cURL

curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{"query": "{ users { id name } }"}'

See Also

Documentation

GraphQL Resources