A transport-agnostic GraphQL-lite bridge over the
CoreEx.DataOData-esque dynamic$filter/$orderby/paging query capability andJsonFilterfield include projection.
CoreEx.Data.GraphQL lets a domain expose its existing QueryAsync/GetAsync repository or service methods
through a single GraphQL-lite /query endpoint, without hand-authoring a GraphQL schema, resolvers, or a
new execution engine. It parses the standard GraphQL-over-HTTP request envelope (query, operationName,
variables) with GraphQL-Parser (AST only — no
execution engine), translates the GraphQL-native where/orderBy structured arguments 1:1 onto the same
OData-esque filter/orderby strings consumed by an entity's existing QueryArgsConfig, exposes list
query roots as Relay Cursor Connections, and flattens the
requested selection set into JSON include paths consumed by CoreEx.Json.JsonFilter — the exact same
projection mechanism CoreEx.AspNetCore's WebApi already uses for $fields/$exclude.
The engine is deliberately transport-agnostic: it references only CoreEx.Data (→ CoreEx.Events →
CoreEx), has zero dependency on ASP.NET Core, and is consumed via the IGraphQLEngine contract
(CoreEx.Data.GraphQL namespace, CoreEx project) so that hosting bridges — such as a minimal API endpoint in
CoreEx.AspNetCore — never need to reference this package's implementation types directly.
- 🧩 Query-only GraphQL-lite bridge: parses a GraphQL document, resolves top-level root fields against
explicitly registered query/item roots — no mutations, subscriptions, cross-repository nested resolvers
(dataloaders), interfaces, unions, or directives in v1. Fragments and inline fragments are rejected with an
explicit
FRAGMENTS_NOT_SUPPORTEDerror rather than being silently ignored. - 🏷️
__typenamesupport: the standard__typenamemeta-field is answerable at every selection depth (Connection, Edge, node, and any nested object), since mainstream GraphQL clients (Apollo Client, Relay, urql) auto-inject it into every selection set for cache normalization. - 🔤 Field aliases at every depth:
field: realNamealiasing is honored throughout the selection set, not just at the root — the response is reshaped (viaGraphQLResponseShaper) to match the client's requested keys. - 🎯 Native GraphQL
where/orderBy, exactQueryArgsConfigcompatibility: list query roots accept a GraphQL-idiomatic, field-keyedwhereinput (operator objects or bare-scalar equality shorthand, composed viaand/or/not) and anorderBylist of field/direction objects — mirroring mainstream conventions (Hot Chocolate, Prisma). These are pure syntax translations (GraphQLFilterTranslator/GraphQLOrderByTranslator) onto the OData-esquefilter/orderbystrings; the translated string is always parsed/validated by the entity's own, unmodifiedQueryArgsConfig(QueryFilterParser/QueryOrderByParser) — so whatever operators and fields aQueryArgsConfigalready exposes for the REST$filter/$orderbyquery strings are supported exactly, with no separate allow-list to maintain. - 🔗 Relay Cursor Connections paging: list query roots return the spec-shaped
edges { node cursor } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } totalCountresponse viafirst/afterforward pagination (backward pagination —last/before— is out of scope for v1 and rejected with an explicit error).totalCountis only computed when the client's selection actually requests it. - 🪆 Nested DTO shape support: a
node's selection set may traverse arbitrarily deep into a DTO's own object graph (e.g.node { address { street city } }) since projection is performed over one already-materialized result viaJsonFilter, not via per-field resolvers. - 🧾 GraphQL-shaped errors:
GraphQLArgumentTranslationException,QueryFilterParserException,QueryOrderByParserException,ValidationException,NotFoundException, and unknown-field errors are mapped to{ message, path, extensions.code }error objects. - 🔍 Spec-compliant introspection:
__schema/__type(name:)(plus__typename) implement the official GraphQL introspection schema, built once from the registered roots (seeInternal.GraphQLIntrospectionSchemaBuilder) and exposed identically viaIGraphQLEngine.GetSchemaAsync(). Each query root'swhere/orderByarguments are described as fully-typed<Item>WhereInput/<Item>OrderByInputINPUT_OBJECTgraphs (and/or/notcomposition,eq/ne/gt/ge/lt/le/in/startsWith/endsWith/containsoperator inputs, and a sharedSortDirectionenum), derived directly from the root's existingQueryArgsConfig.ToJsonSchema()field descriptions — no extra configuration needed; see Non-goals below for the remaining simplifications. - 🧷 Explicit, code-based registration:
services.AddCoreExGraphQLLite((o, sp) => o.AddQuery(...).AddGet(...))— no attribute-based auto-discovery. - 🔌
WebApipipeline integration: theCoreEx.AspNetCorehosting bridge (MapCoreExGraphQLLite) executes throughCoreEx.AspNetCore.Http.WebApi.PostAsync<GraphQLLiteResponse>(...)— the same response-shaping pipeline every other CoreEx REST endpoint uses — so an unexpected bug that escapes the engine's own exception mapping still surfaces as a standard CoreExProblemDetailsresponse instead of an unhandled 500. - 📡 OpenTelemetry:
WithCoreExGraphQLTelemetry()(inOpenTelemetry.Trace,CoreEx.Data.GraphQLpackage) wiresGraphQLEngineInvoker's activity source into the OTEL tracer provider, so everyExecuteAsynccall produces a span alongside the rest of a host's CoreEx instrumentation.
| Type | Description |
|---|---|
GraphQLEngine |
The concrete IGraphQLEngine implementation: parses the document, resolves root fields, applies JsonFilter projection, and assembles the GraphQLEngineResult (including the Relay Connection shape for query roots). ExecuteAsync is wrapped by GraphQLEngineInvoker for OpenTelemetry tracing. |
GraphQLEngineInvoker |
InvokerBase<GraphQLEngine> used internally by ExecuteAsync; its activity source is registered via WithCoreExGraphQLTelemetry(). |
GraphQLLiteOptions |
The DI options builder: AddQuery<TItem> (list roots bound to a QueryArgsConfig + QueryAsync-shaped delegate), AddGet<TItem> (single-item roots), and AddReferenceDataQueries(sp, queryArgsConfig, prefix, excludeTypes) (bulk-registers every reference data type known to ReferenceDataOrchestrator as a query root, keyed by its alternate/GraphQL-friendly name). |
GraphQLExtensions |
AddCoreExGraphQLLite(IServiceCollection, Action<GraphQLLiteOptions, IServiceProvider>) registration extension, and (in GraphQLExtensions.OpenTelemetry.cs) WithCoreExGraphQLTelemetry(OpenTelemetryBuilder). |
GraphQLQueryRoot / GraphQLItemRoot |
Registered list-query and single-item root field descriptors. |
Internal.GraphQLFilterTranslator / Internal.GraphQLOrderByTranslator |
Translate the GraphQL-native where/orderBy structured arguments to the OData-esque filter/orderby strings consumed by QueryArgsConfig. |
Internal.GraphQLCursor |
Encodes/decodes the opaque, offset-based Relay Cursor Connections cursor. |
IGraphQLEngine (in CoreEx, namespace CoreEx.Data.GraphQL) |
The transport-agnostic contract: ExecuteAsync(document, operationName, variables, ct) and GetSchemaAsync(ct). |
GraphQLEngineResult / GraphQLEngineError (in CoreEx, namespace CoreEx.Data.GraphQL) |
The plain result/error POCOs returned by ExecuteAsync, mirroring the GraphQL-over-HTTP response shape. |
// Program.cs (or a domain composition extension)
builder.Services.AddCoreExGraphQLLite((o, sp) =>
{
o.AddQuery<ProductLite>("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => await CoreEx.ExecutionContext.GetRequiredService<IProductReadService>().QueryAsync(qa, pa, ct).ConfigureAwait(false))
// GetIdentifier<TId> validates the named argument (default "id") for presence, converting it to TId via TId.Parse where the boxed argument value isn't already an exact
// match (e.g. a variable-supplied Int arrives boxed as long, not int) and throws an ArgumentException - mapped by the engine to an ARGUMENT_ERROR GraphQL error - if
// it is missing, empty, or not convertible to TId, instead of an unhandled KeyNotFoundException/NullReferenceException surfacing as an opaque EXECUTION_ERROR.
.AddGet<Product>("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService<IProductReadService>().GetAsync(args.GetIdentifier<string>(), ct));
});
// ...
app.MapCoreExGraphQLLite("/api/query"); // Additive GraphQL-lite bridge alongside the existing REST endpoints.
// Optional: OpenTelemetry tracing for GraphQLEngine.ExecuteAsync, alongside the rest of the host's CoreEx instrumentation.
builder.WithCoreExTelemetry()
.WithCoreExGraphQLTelemetry()
.UseOtlpExporter();To expose every reference data type known to ReferenceDataOrchestrator as a GraphQL query root (one root per type, keyed by its alternate/GraphQL-friendly name), use AddReferenceDataQueries instead of one AddQuery call per type:
builder.Services.AddCoreExGraphQLLite((o, sp) =>
{
// Bulk-register all ref-data types as query roots (prefix defaults to "ref_"; use null for no prefix).
o.AddReferenceDataQueries(sp, ReferenceDataQueryArgsConfig.Default, prefix: "ref_");
// Mix with regular entity roots as needed.
o.AddQuery<ProductLite>("products", ProductQueryArgsConfig.Default, async (qa, pa, ct) => ...);
});Every reference data type known to ReferenceDataOrchestrator is exposed — not just types with a registered alternate name. Each root is named <prefix><name> (hyphens replaced with underscores, since GraphQL field names cannot contain them), where <name> is the type's IReferenceDataProvider.AlternateNames entry where one is registered, otherwise the type's own Type.Name. Pass excludeTypes to opt specific types out of this bulk registration.
A hosting bridge (e.g. MapCoreExGraphQLLite in CoreEx.AspNetCore) resolves IGraphQLEngine from DI and
calls ExecuteAsync with the parsed request envelope, returning { data, errors } as the HTTP response
body via WebApi.PostAsync<GraphQLLiteResponse>(...) — the same response-shaping pipeline every other CoreEx
REST endpoint uses, so ProblemDetails/exception handling and logging middleware still apply as a safety net
for anything the engine's own exception mapping doesn't catch. Since IGraphQLEngine is registered as a
singleton, root resolvers that need scoped dependencies (e.g. a repository or application service) should
resolve them per-invocation rather than capturing an instance resolved from the root IServiceProvider at
registration time — as shown above via CoreEx.ExecutionContext.GetRequiredService<T>(), which reads from
the ambient ExecutionContext's scoped service provider (set by the UseExecutionContext() middleware every
CoreEx host already registers), so no extra IHttpContextAccessor wiring is required.
A client queries the products root using native GraphQL where/orderBy and first/after Relay paging
— translated 1:1 to ProductQueryArgsConfig's existing filter/orderby support:
{
products(where: { sku: { startsWith: "spec" } }, orderBy: [{ text: DESC }], first: 10) {
edges {
node { sku text }
cursor
}
pageInfo { hasNextPage endCursor }
totalCount
}
}- No mutations or subscriptions — read/query only.
- No cross-repository nested resolvers (dataloaders/N+1 batching) — a
node's selection set may traverse nested properties already present on the DTO returned by a singleQueryAsync/GetAsynccall, but cannot request a field that would require invoking a different registered root. - No fragments (spreads or inline), interfaces, unions, or directives — a fragment in the document produces
an explicit
FRAGMENTS_NOT_SUPPORTEDerror rather than being silently skipped. - No backward pagination (
last/before) — Relay Cursor Connectionsfirst/afterforward pagination only; alast/beforeargument produces an explicit error rather than being silently ignored. - No standard GraphQL SDL export — the schema is only queryable at runtime via
__schema/__type(or the equivalentIGraphQLEngine.GetSchemaAsync()), not printable as a.graphqlSDL document. where/orderByargument field names in the generated<Item>WhereInput/<Item>OrderByInputtypes are the all-lowercase names already reported byQueryArgsConfig.ToJsonSchema()(e.g.subcategoryrather thansubCategory), not the DTO's camelCase JSON naming — cosmetic only, since field matching is case-insensitive.- Every field of a given JSON-schema type (
string/integer/number/boolean) shares one generic<Type>FilterInputoperator set (e.g.StringFilterInput) rather than a per-field-restricted shape, so a field may advertise an operator (e.g.gt) its specific configuration does not actually permit —QueryFilterParserstill enforces the real legality at execution time (defense in depth). - CLR
enumand reference-data (IReferenceData) output properties are described as theStringscalar (matching their actual JSON wire representation), not a specENUMtype. - A single-item
AddGetroot only advertises anid: ID!argument where its registered item type implementsIReadOnlyIdentifier<TId>, since theAddGetregistration API does not declare an argument shape today; it always advertisesincludeText/includeInactivealongside it, since the engine honours both for item roots too (see below). - Not a replacement for the REST
$filter/$orderby/$fieldsquery-string endpoints — this is an additive bridge sharing the same underlying pipeline. - Introspection is disabled by default (
GraphQLLiteOptions.EnableIntrospection = false) — a request for__schema/__typeproduces anINTROSPECTION_DISABLEDerror until explicitly enabled (e.g. so client tooling like GraphiQL, Postman, or Apollo/Relay codegen can introspect the schema in development). The directIGraphQLEngine.GetSchemaAsync()API is unaffected by this toggle. Debug-level query root logging omits literal filter values by default —GraphQLQueryRoot'sDebuglog only reports whether awhere/orderBywas specified, not the literal OData-esque text (which embeds client-supplied filter values verbatim). SetGraphQLLiteOptions.EnableSensitiveDataLogging = true(mirrors EF Core's option of the same name) to log the exact filter/order-by text while debugging — do not enable it against a shared/production log sink.- No query-cost/complexity budget beyond
MaxRootFields—GraphQLLiteOptions.MaxRootFields(defaultnull, unlimited) only bounds the number of root fields (including aliased repeats) in one document; there is no per-request node/complexity scoring, and nested selection depth is bounded only by the underlyingGraphQL-Parserlibrary's ownMaxDepthdefault. - Introspection's advertised nesting depth is an approximation of the runtime cap —
__typefield resolution mirrorsGraphQLTypeShape's runtime depth cap (MaxDepth = 8) along each type's first traversal path, but the introspection type registry is keyed by CLR type name and short-circuits on repeat visits (cycle guard), so a type reachable both shallow and deep keeps whichever depth it was first visited at — it does not perform a true per-path re-evaluation for every possible route to that type. - No authorization is applied by default —
MapCoreExGraphQLLitemounts the endpoint anonymously unless the caller supplies its ownconfiguredelegate (e.g.rb => rb.RequireAuthorization()); since this endpoint can reach the same underlying data as[Authorize]-protected REST controllers, hosts should apply equivalent authorization explicitly.
An AGENTS.md file is included with this package. AI coding assistants (GitHub Copilot, Claude, Cursor, etc.) that support workspace-injected package documentation will automatically surface concise usage guidance, code examples, and Do Not rules for this package without requiring a local CoreEx checkout.