Provides a transport-agnostic GraphQL-lite bridge that translates native GraphQL where/orderBy/Relay Cursor Connections paging 1:1 onto an entity's existing QueryArgsConfig-driven QueryAsync/GetAsync pipeline — no hand-authored schema, resolvers, or execution engine.
Register roots explicitly — no attribute-based auto-discovery. Each AddQuery/AddGet binds a GraphQL root field name to an entity's existing QueryArgsConfig.Default and application-service method.
// 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 to TId via TId.Parse where needed (e.g. a variable-supplied Int arrives boxed
// as long, not int), and throws an ArgumentException, mapped by the engine to ARGUMENT_ERROR, if missing/empty/not convertible to TId.
.AddGet<Product>("product", (args, ct) => CoreEx.ExecutionContext.GetRequiredService<IProductReadService>().GetAsync(args.GetIdentifier<string>(), ct));
});
// ...
app.MapCoreExGraphQLLite("/api/query"); // CoreEx.AspNetCore hosting bridge; defaults to "/query".
// Optional: OpenTelemetry tracing for GraphQLEngine.ExecuteAsync.
builder.WithCoreExTelemetry().WithCoreExGraphQLTelemetry().UseOtlpExporter();To expose every reference data type registered with ReferenceDataOrchestrator as a query root in one call, use AddReferenceDataQueries instead of one AddQuery call per type:
builder.Services.AddCoreExGraphQLLite((o, sp) =>
{
// Bulk-register all ref-data alternate names 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 the ReferenceDataOrchestrator is exposed as a root — not just types that declare an AlternateNames entry. Each root is named <prefix><name> (hyphens replaced with underscores), where <name> is the type's registered alternate name where one exists, otherwise the type's own Type.Name. Use excludeTypes to opt specific types out. The filter/order config defaults to ReferenceDataQueryArgsConfig.Default (code/text filters, code/text/sortOrder ordering) unless a custom QueryArgsConfig is passed.
Because IGraphQLEngine is registered as a singleton, resolve scoped dependencies (repositories, application services) per-invocation rather than capturing an instance 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 IHttpContextAccessor registration/wiring is needed.
MapCoreExGraphQLLite executes through WebApi.PostAsync<GraphQLLiteResponse>(...) — the same response pipeline every CoreEx REST endpoint uses — so an unexpected bug that escapes GraphQLEngine's own exception mapping still surfaces as a standard ProblemDetails response (logged) rather than an unhandled 500.
Clients use native GraphQL where/orderBy and first/after Relay paging — translated 1:1 to the registered QueryArgsConfig's existing filter/orderby support, so whatever operators/fields a QueryArgsConfig exposes for the REST $filter/$orderby query strings are supported exactly:
{
products(where: { sku: { startsWith: "spec" } }, orderBy: [{ text: DESC }], first: 10) {
edges {
node { sku text }
cursor
}
pageInfo { hasNextPage endCursor }
totalCount
}
}where supports bare-scalar equality shorthand ({ sku: "ABC" }) or operator objects ({ sku: { startsWith: "spec" } }), composed via and/or/not. Both where and orderBy are pure syntax translations — real field/operator validation happens downstream in the entity's own, unmodified QueryArgsConfig (same as the REST $filter/$orderby query strings), so there is no separate allow-list to maintain.
- Do not add mutations, subscriptions, or cross-repository nested resolvers (dataloaders) — this is a read-only, single-root-per-selection bridge by design (v1).
- Do not expect
last/beforebackward pagination — onlyfirst/afterforward pagination is supported; alast/beforeargument produces an explicitARGUMENT_ERROR. - Do not treat GraphQL-lite's introspection as fully spec-parity:
__schema/__type(name:)/__typenameare real, spec-compliant, and built once from the registered roots (seeInternal.GraphQLIntrospectionSchemaBuilder) — tooling that fetches a schema (Postman, Nitro, Apollo Sandbox) will work, including autocomplete onwhere/orderBysince these are described as real<Item>WhereInput/<Item>OrderByInputtypes (derived automatically from the root'sQueryArgsConfig.ToJsonSchema()— no extra config). Remaining simplifications: every field of a given schema type shares one generic<Type>FilterInputoperator set (may over-advertise an operator a specific field doesn't actually permit — enforced at execution time regardless), input field names are all-lowercase (not camelCase), enums/ref-data output properties are declared asString(not a specENUM), and anAddGetroot only gets anid: ID!argument where its item type implementsIReadOnlyIdentifier<TId>(it always advertisesincludeText/includeInactivetoo, since the engine honours both for item roots the same way it does for query roots). - Do not capture a scoped service from the root
IServiceProviderin a resolver closure — resolve it per-invocation from the current request's scope instead (see Registration above). - Do not bypass a
QueryArgsConfigto add new filter/sort capability for GraphQL only — add the field/operator to the entity's existingQueryArgsConfigso REST and GraphQL stay in exact lockstep. - Do not assume introspection (
__schema/__type) works out of the box —GraphQLLiteOptions.EnableIntrospectiondefaults tofalse(secure-by-default); a request producesINTROSPECTION_DISABLEDuntil a host explicitly opts in (see the Contoso Products sample'sProgram.csfor the opt-in pattern).IGraphQLEngine.GetSchemaAsync()(the direct API) is unaffected. - Do not assume
MapCoreExGraphQLLiteapplies authorization — the endpoint is anonymous by default; passconfigure: rb => rb.RequireAuthorization()(or an equivalent policy) explicitly since this endpoint can reach the same data as[Authorize]-protected REST controllers. - Do not assume every unexpected resolver exception's real message reaches the client —
GraphQLEngine's catch-all mirrorsWebApi's REST contract exactly: an unexpected (non-IExtendedException) exception is always logged and only exposes its real message whenCoreEx:IncludeExceptionInProblemDetailsis enabled (defaultfalse); knownIExtendedExceptiontypes (NotFoundException,ValidationException,ConflictException,DuplicateException,ConcurrencyException,AuthenticationException,AuthorizationException,BusinessException, etc.) surface their own safe message/error code and are logged only whenShouldBeLoggedistrue. - Do not expect the literal
where/orderBytext inDebug-level logs by default —GraphQLQueryRootonly logs whether a filter/order-by was specified, not its text (which embeds client-supplied values verbatim). SetGraphQLLiteOptions.EnableSensitiveDataLogging = true(mirrors EF Core's option of the same name) to see the exact text while debugging.
- README — full capability list, key types, and non-goals.
- CoreEx.Data —
QueryArgsConfig,QueryArgs,PagingArgs, and the safe dynamic-query pipeline this package bridges to. - CoreEx.AspNetCore —
MapCoreExGraphQLLitehosting bridge and the GraphQL-over-HTTP request/response envelope. - Hosts layer — the GraphQL-lite query bridge in a real API host's
Program.cs. - Patterns — dynamic query and field-projection patterns shared by REST and GraphQL-lite.