@overture-stack/arranger-graphql-router

August 26, 2026 ยท View on GitHub

Core GraphQL routing library for a single Arranger catalogue. Converts an OpenSearch or Elasticsearch index into a working GraphQL API with faceted search, aggregations, SQON filtering, download support, and optional network search federation.

This module is the engine inside apps/search-server. It can also be used directly to embed Arranger search into a custom Express application.


Installation

npm install @overture-stack/arranger-graphql-router

Quick start

import express from 'express';
import arrangerRouter from '@overture-stack/arranger-graphql-router';

const app = express();

const router = await arrangerRouter({
	configs: {
		esHost: 'http://localhost:9200',
		esIndex: 'file_centric',
		documentType: 'File',
	},
});

app.use('/graphql', router);
app.listen(5050);

For a production-ready setup with multicatalogue support, config file loading, environment variable wiring, and introspection endpoints, use apps/search-server directly.


API

arrangerRouter(options): default export

Creates and returns an Express Router configured for a single Arranger catalogue. Returns a Promise<Router>.

import arrangerRouter from '@overture-stack/arranger-graphql-router';

const router = await arrangerRouter(options);

Options

OptionTypeDescription
configsPartial<ConfigsObject>Catalogue configuration. See Configuration.
esClientSearchClientOptional: bring your own ES/OS client. When omitted, one is created from configs.esHost, configs.esUser, and configs.esPass.
getServerSideFilterGetServerSideFilterFnOptional: callback invoked per request to inject a SQON filter for access control. See Server-side filters.
configsSourcestringDeprecated: will be removed in v3.2. Pass configs directly instead.

Configuration

configs accepts Partial<ConfigsObject>, defined in @overture-stack/arranger-types. The most commonly used properties are:

Search engine connection

PropertyTypeDefaultDescription
esHoststring'http://localhost:9200'OpenSearch or Elasticsearch node URL.
esUserstring''Basic auth username.
esPassstring''Basic auth password.
searchEngine'opensearch' | 'elasticsearch'auto-detectClient type. Leave unset to detect from the cluster version API on startup.

Catalogue identity

PropertyTypeDescription
esIndexstringES/OS index to query. Required.
documentTypestringGraphQL type name for documents in this catalogue. Required.

Feature flags

PropertyTypeDefaultDescription
disableDownloadsbooleanfalseDisable the TSV/file download endpoint.
disableFiltersbooleanfalseDisable SQON filter support on queries.
disableGraphQLIntrospectionbooleanfalse (true when NODE_ENV=production)Disable GraphQL's built-in __schema/__type introspection system. Recommended in production. Caveat: remote nodes used in a network aggregation deployment must keep this disabled; see that section for details.
disablePlaygroundbooleanfalseDisable the GraphQL Playground UI.
enableGraphQLBatchingbooleanfalseEnable array-based GraphQL query batching (sending multiple operations in a single HTTP request). Disabled by default: unrestricted batching can be used to bypass request-level rate limiting and amplify the cost of a single request. Only enable if a consumer genuinely relies on batched requests.
enableSetsbooleanfalseEnable saved Sets. Sets are disabled by default; set to true to activate.

Table

PropertyTypeDefaultDescription
table.maxResultsWindownumber10000Maximum hits returnable per query (ES/OS default).
table.rowIdFieldNamestring'id'ES field used as the row identifier in table results.

Query limits

PropertyTypeDefaultDescription
maxAliasesnumberunlimitedMaximum aliases per GraphQL query.
maxDepthnumberunlimitedMaximum depth of a GraphQL query.

A catalogue can federate aggregation queries across multiple remote Arranger nodes. Add a network block to configs:

const router = await arrangerRouter({
	configs: {
		documentType: 'file',
		esHost: 'http://localhost:9200',
		esIndex: 'file_centric',
		network: {
			// Runs once per node per query. Use it to forward auth to remote nodes.
			customizeRemoteRequest: ({ context, remoteNode }) => ({
				headers: {
					Authorization: context.request.headers.get('Authorization') ?? '',
				},
			}),
			localNode: {
				displayName: 'Local',
				nodeId: 'local',
			},
			remoteNodes: [
				{
					displayName: 'Node A',
					documentType: 'file', // the remote's root field; `Aggregations` is appended internally
					graphqlUrl: 'http://node-a:5050/graphql',
					nodeId: 'node-a',
				},
				{
					displayName: 'Node B',
					documentType: 'file',
					graphqlUrl: 'http://node-b:5050/graphql',
					nodeId: 'node-b',
				},
			],
		},
	},
});

Network config fields

FieldDescription
customizeRemoteRequestCallback invoked once per node per query, receiving { context, remoteNode } and returning request properties (currently headers) to add to that node's outgoing request.
localNode.displayNameHuman-readable label for this node's results in aggregation responses. Omit the whole localNode block to federate over remote nodes only.
localNode.nodeIdStable identifier for this node, used by the nodesFilter query argument.
remoteNodes[].displayNameHuman-readable label for this remote node's results. Also used to match responses back to nodes, so keep it unique across the network.
remoteNodes[].documentTypeThe remote catalogue's documentType, meaning its root GraphQL field (e.g. file). Aggregations is appended to this value during field discovery, so give the bare document type, not fileAggregations.
remoteNodes[].graphqlUrlGraphQL endpoint URL of the remote Arranger instance.
remoteNodes[].nodeIdStable identifier for this node, used by the nodesFilter query argument.

With apps/search-server

When running apps/search-server, this config lives in network.json inside the catalogue's config directory. A template is at apps/search-server/configTemplates/network.json.

A JSON file cannot express a callback, so search-server accepts two extra declarative properties that this library does not, and normalizes them into a customizeRemoteRequest function before calling arrangerRouter:

Field (search-server only)Description
remoteRequests.headersHeader names to copy from the incoming request onto the outgoing request to every remote node.
remoteNodes[].requests.headersHeader names to forward to this specific node. Replaces remoteRequests.headers for that node, rather than merging with it.

Passing either of these to arrangerRouter directly has no effect: at this layer, supply customizeRemoteRequest instead.

Field merging

Nodes do not need identical field sets. The federated schema is the union of the supported aggregation fields found across all nodes, deduplicated by field name and type. A field present on only one node still appears in the schema, and each node is only queried for the fields it actually has.

When a node lacks a requested field, it contributes one sentinel bucket carrying its total hits for that query, so counts still add up:

{ "key": "___aggregation_not_available___", "doc_count": 4210 }

Merging is keyed on field name and aggregation type, so nodes only combine on a field when both name it identically. Only the Aggregations type federates; NumericAggregations fields are excluded from the federated schema entirely.

Introspection requirement: At startup, each remote node's aggregation field types are discovered via a __type GraphQL introspection query. A remote node with disableGraphQLIntrospection: true fails schema discovery and is reported as an errored node with zero hits for the lifetime of this server's process. Since the flag defaults to true when NODE_ENV=production, any node serving as a remote target must explicitly set it to false. A fix that replaces this with a REST /introspection/fields call is tracked in tech-debt and planned for the yoga migration.

For the query shape, per-node status reporting, failure behaviour, and full limitations, see the Federated search documentation.


Server-side filters

getServerSideFilter injects a SQON filter on every query: typically used for access control. Configuring it is optional; a router without one applies no access control and serves every document.

The callback must return a SqonNode for every request it receives, including unauthenticated ones. There is no "no filter" return value: an absent filter, or one with no clauses, would match every document, so it is rejected rather than applied. Both outcomes a caller might mean by "nothing to apply here" have their own explicit value:

IntentReturn
This request may see everythinggetDefaultServerSideFilter()
This request may see nothingan in clause with an empty value list
import arrangerRouter, { getDefaultServerSideFilter } from '@overture-stack/arranger-graphql-router';
import type { GetServerSideFilterFn } from '@overture-stack/arranger-types/configs';

const getServerSideFilter: GetServerSideFilterFn = (context) => {
	const userId = context.req.headers['x-user-id'];

	// Decide deliberately what an unidentified caller may see. Returning the default below grants
	// them everything; the commented alternative grants them nothing.
	if (!userId) {
		return getDefaultServerSideFilter();
		// return { op: 'in', content: { fieldName: 'acl', value: [] } };
	}

	return {
		op: 'and',
		content: [{ op: 'in', content: { fieldName: 'acl', value: [String(userId)] } }],
	};
};

const router = await arrangerRouter({ configs, getServerSideFilter });

Note fieldName, not field. A content clause using any other key does not describe a field, and the resulting filter restricts nothing.

The returned filter is composed with any SQON the client provides, and the client cannot remove or weaken it: composition happens after the client's filter is parsed, and a filter is required to survive to the query. It is applied to record, aggregation, and set queries.

Aggregations are worth one note, because they are the case where "the filter is applied" is easy to assume and hard to see. A facet does not apply the caller's own filter on the field it is aggregating, so that selecting a value does not collapse that facet to the single value chosen. That exemption is for the caller's filter only; the server-side filter is re-applied to every aggregation, including one on the same field it restricts. So a facet on an access-controlled field shows only the values that caller may see.

In multicatalogue mode the filter is global: it applies to all catalogues mounted under this router instance.


Other exports

buildSearchClient(options)

Creates an OpenSearch or Elasticsearch client:

import { buildSearchClient } from '@overture-stack/arranger-graphql-router';

const client = await buildSearchClient({
	client: 'opensearch', // 'elasticsearch', or omit to auto-detect
	node: 'http://localhost:9200',
	username: 'elastic',
	password: 'secret',
});

resolveCatalogueFields(mapping, extendedFields)

Transforms a raw ES/OS index mapping into Arranger's field descriptor format. Useful for custom introspection tooling.

mergeConfigs(fallback, custom)

Deep-merges two ConfigsObject values, with custom taking precedence. Preserves nested objects rather than replacing them: the same merge used internally by arrangerRouter when combining defaults with caller-supplied config.

SearchClient, SupportedClientTypes

Types for the search client. Import when you need to type a client created externally:

import type { SearchClient, SupportedClientTypes } from '@overture-stack/arranger-graphql-router';

Sub-path exports

Import pathContents
@overture-stack/arranger-graphql-router/utilsInternal utilities (ajax, runGraphQLQuery). Not part of the stable API.
@overture-stack/arranger-graphql-router/downloadDownload route helpers. Consumed internally by arrangerRouter.