Authoring a Protocol Adapter
A protocol adapter lets BifrostQL answer requests on a wire that isn’t GraphQL-over-HTTP — OData, gRPC, a custom binary framing, an in-process pipe. This guide walks the full authoring surface: the hosting contract, registration, identity, the intent APIs, Kestrel hosting for raw TCP, and the conformance suite every adapter must pass.
Read the concept page first if you haven’t: the short version is that an adapter owns only its wire and codec. Reads execute through IQueryIntentExecutor, writes through IMutationIntentExecutor, and identity is projected through IBifrostAuthContextFactory — never through your own claim mapping, SqlExecutionManager, or a database connection.
The contract
Section titled “The contract”public interface IProtocolAdapter{ /// Begins accepting protocol requests. Called once by the host during /// startup; a thrown exception aborts host startup. Task StartAsync(CancellationToken cancellationToken);
/// Stops accepting protocol requests and drains in-flight work. Called /// once by the host during graceful shutdown. Task StopAsync(CancellationToken cancellationToken);}Each registered adapter is wrapped in its own IHostedService, so its lifecycle is the host’s lifecycle. Two rules follow:
- Fail fast in
StartAsync. A port bind failure, bad certificate, or missing dependency must throw — that aborts host startup instead of leaving a healthy-looking host with a dead front door. - No
HttpContextdependency. The contract has none on purpose; identity arrives on your wire and is projected via the auth-context factory (below).
Registration
Section titled “Registration”Register the adapter type on the setup options. It is resolved from DI (constructor injection works) and started/stopped by the host.
Single database:
builder.Services.AddBifrostQL(o => o .AddProtocolAdapter<MyProtocolAdapter>());Multi-database endpoints:
builder.Services.AddBifrostEndpoints(o =>{ o.AddEndpoint(e => { e.ConnectionString = "..."; e.Path = "/graphql"; }); o.AddEndpoint(e => { e.ConnectionString = "..."; e.Path = "/reporting"; }); o.AddProtocolAdapter<MyProtocolAdapter>();});Both BifrostSetupOptions.AddProtocolAdapter<T>() and BifrostMultiDbOptions.AddProtocolAdapter<T>() are additive and idempotent per type.
With multiple endpoints registered, every intent must name its target endpoint path (Endpoint = "/reporting"); with a single endpoint, null selects it. An unknown path fails fast — there is no fallback to a different database.
Identity: the auth-context factory
Section titled “Identity: the auth-context factory”Resolve IBifrostAuthContextFactory from DI and project every request’s principal through it. It is the same seam the HTTP GraphQL and binary WebSocket gates use, so fail-closed semantics (including the unmapped-OIDC-issuer throw) hold identically on your wire:
// The HttpContext here is only the carrier the factory contract requires —// no HTTP request exists. RequestServices must be set so OIDC claim-mapper// resolution (and its fail-closed unmapped-issuer path) works as on HTTP gates.var identityCarrier = new DefaultHttpContext { RequestServices = _services };if (principal is not null) identityCarrier.User = principal;var userContext = _authFactory.CreateUserContext(identityCarrier);An authenticated principal yields the full claim projection; an unauthenticated request yields an empty context, which the tenant transformers downstream refuse on tenant-filtered tables. A second overload, CreateUserContext(context, existing), merges entries your frontend already parsed from the wire — identity-derived keys always win.
Never map claims yourself and never fabricate tenant/user keys in the user context: that is exactly the drift the shared factory exists to prevent.
Reads: the query intent API
Section titled “Reads: the query intent API”IQueryIntentExecutor executes a programmatic GqlObjectQuery — no GraphQL text, no parser:
public interface IQueryIntentExecutor{ Task<IDbModel> GetModelAsync(string? endpoint = null); Task<QueryIntentResult> ExecuteAsync(QueryIntent intent, CancellationToken ct = default);}Build the query tree against the model returned by GetModelAsync for the same endpoint, so GqlObjectQuery.DbTable references the cached model the intent executes against:
var model = await _executor.GetModelAsync(request.Endpoint);var table = model.GetTableFromDbName(request.Table); // unknown table throws
var query = new GqlObjectQuery{ DbTable = table, SchemaName = table.TableSchema, TableName = table.DbName, GraphQlName = table.GraphQlName, Path = table.GraphQlName,};foreach (var column in request.Columns) query.ScalarColumns.Add(new GqlObjectColumn(column));if (request.Filter is not null) query.Filter = TableFilter.FromObject(request.Filter, table.DbName);
var result = await _executor.ExecuteAsync(new QueryIntent{ Query = query, UserContext = userContext, Endpoint = request.Endpoint,}, cancellationToken);QueryIntentResult carries:
Rows— the root result set as column-name → value dictionaries, converted with the same coercions GraphQL responses get;TotalCount— unpaged count, populated only when the intent setGqlObjectQuery.IncludeResult;Sql— the executed parameterized SQL text (placeholders, never inlined values) for diagnostics and auditing.
Execution routes through SqlExecutionManager.ExecuteIntentAsync, so every registered filter transformer and column read guard applies unconditionally — your adapter has no API surface to skip them.
Writes: the mutation intent API
Section titled “Writes: the mutation intent API”IMutationIntentExecutor is the write counterpart. Intents are plain data:
var result = await _mutationExecutor.ExecuteAsync(new MutationIntent{ Table = "orders", // database table name; unknown → throws Action = MutationIntentAction.Update, // Insert | Update | Delete Data = values, // column values, GraphQL-field or db-column // names, case-insensitive PrimaryKey = new object?[] { id }, // optional positional PK values, // declared key-column order (composite-safe) UserContext = userContext, Endpoint = request.Endpoint,}, cancellationToken);Semantics mirror the GraphQL mutation fields:
- Insert —
Datacarries the new row.PrimaryKeyis invalid on insert and fails fast (accepting-and-ignoring it could silently turn an intended update into a duplicate insert). Returns the generated identity. - Update —
Datacarries the SET columns; address the row viaPrimaryKey(wins over key columns present inData). A table with a concurrency token requires the token’s last-read value inData. Returns the key (single-key) or affected count (composite-key). - Delete —
Datacarries predicate columns,PrimaryKeyoverlays them (the same merge GraphQL’s_primaryKeyperforms, arity-checked). Returns the affected row count —0when a tenant or policy scope made the write a no-op.
Execution routes through TableMutationPipeline — the seam shared with the GraphQL resolver — so the full mutation transformer chain (policy, state machine, enum mapping, validation, soft delete, tenant isolation, audit, optimistic concurrency) applies unconditionally.
A read-only adapter simply never takes a dependency on IMutationIntentExecutor.
Hosting a raw (non-HTTP) TCP protocol
Section titled “Hosting a raw (non-HTTP) TCP protocol”Do not open a bare Socket accept loop. Bind the port through Kestrel’s connection middleware and let a connection handler decode frames and delegate to the adapter:
webBuilder.ConfigureKestrel(kestrel => kestrel.ListenAnyIP(9090, listen => listen.UseConnectionHandler<MyProtocolConnectionHandler>()));Kestrel then owns socket accept, backpressure, and shutdown draining; your adapter’s StartAsync/StopAsync manage only protocol-level state (codec tables, session registries, …).
Worked example: the Echo adapter
Section titled “Worked example: the Echo adapter”The repository ships a reference adapter with zero wire code — EchoProtocolAdapter in tests/BifrostQL.Server.Test/ProtocolAdapterTests.cs. Its “wire” is a plain request object (table, columns, filter, principal, endpoint), which makes it the smallest complete demonstration of the pattern:
- Constructor takes
IQueryIntentExecutor,IMutationIntentExecutor,IBifrostAuthContextFactory, andIServiceProviderfrom DI. StartAsync/StopAsynctrack lifecycle; requests before start (or after stop) are refused.- Each read projects the principal through the auth factory, builds a
GqlObjectQueryagainstGetModelAsync’s model, and executes aQueryIntent. - Each write projects identity the same way and executes a
MutationIntent.
A real adapter is exactly this shape plus listening and a codec. The accompanying ProtocolAdapterTests prove the hosting contract (started once, stopped once, StartAsync failure aborts host startup) and that tenant isolation holds end to end.
Proving safety: the conformance kit
Section titled “Proving safety: the conformance kit”Every adapter must pass the shared security-conformance suite in tests/BifrostQL.AdapterConformance instead of copying its tests. A passing suite proves the adapter is not a security hole around the GraphQL pipeline: security transformers apply, SQL is parameterized, policy read guards hold (including the filter-as-oracle case), missing identity fails closed, and — for write-capable adapters — mutations run the transformer chain.
Derive the suite in your adapter’s test project:
public sealed class MyAdapterConformanceTests : ProtocolAdapterConformanceTests{ // Register the adapter on the fixture's endpoint options. protected override void RegisterAdapter(BifrostMultiDbOptions options) => options.AddProtocolAdapter<MyAdapter>();
// Encode the request in YOUR wire format, send it through the adapter's // real request path, decode the rows. Server-side rejections must // propagate as exceptions carrying the server's error text — a suite // that swallows errors cannot prove fail-closed behavior. protected override Task<IReadOnlyList<IReadOnlyDictionary<string, object?>>> ExecuteReadAsync( ConformanceReadRequest request) { ... }
// Write-capable adapters only: protected override bool AdapterSupportsMutations => true; protected override Task<object?> ExecuteMutationAsync( ConformanceMutationRequest request) { ... }}The base class owns the whole fixture: a per-suite in-memory SQLite database, the security metadata rules (tenant-filter, soft-delete, policy-read-deny), the test host, and a SQL-capture observer it asserts against. Your derivation supplies only the wire translation.
The mutation opt-out is for genuinely read-only adapters. AdapterSupportsMutations defaults to false, which skips the mutation facts — legitimate when your adapter exposes no write surface at all. An adapter that exposes any write surface must return true and override ExecuteMutationAsync; opting out while shipping writes would leave the write path unproven against tenant isolation and soft-delete semantics.
EchoProtocolAdapterConformanceTests in tests/BifrostQL.Server.Test is the reference derivation — about forty lines, since the base class supplies every fact.
For a full production adapter built on exactly this surface, see the
PostgreSQL Wire Protocol (pgwire) guide — a read-only
postgres front door that reads through IQueryIntentExecutor, projects identity through
IBifrostAuthContextFactory, hosts raw TCP under a Kestrel connection handler, and
derives the conformance kit over the wire.
Checklist
Section titled “Checklist”-
IProtocolAdapterimplemented;StartAsyncthrows on bind/listen failure. - Registered via
AddProtocolAdapter<T>()(single or multi-db options). - Identity projected through
IBifrostAuthContextFactory— no custom claim mapping. - Reads only via
IQueryIntentExecutor, writes only viaIMutationIntentExecutor. - No reference to
SqlExecutionManager,DbConnection, or SQL text anywhere in the adapter. - Raw TCP hosted under a Kestrel connection handler, not a bare socket loop.
- Conformance suite derived and green;
AdapterSupportsMutationsmatches the adapter’s real write surface.