Skip to content

About

SharpPortico - Incremental Source Generator for OpenAPI to gRPC (C# 14 /15 .NET 10/11)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

SharpPortico

Incremental Source Generator: OpenAPI 3.0/3.1 β†’ gRPC + Protobuf + C# 14

Sponsor NuGet Changelog

SharpPortico is a compile-time source generator that converts OpenAPI specifications (YAML or JSON) into production-quality gRPC services, protobuf messages, and modern C# 14 client/server code β€” zero reflection, NativeAOT-safe, fully AOT compatible. An optional proxy mode turns it into a gRPC↔REST gateway for legacy REST APIs.

Runs on .NET 10 and .NET 11. The generator itself is netstandard2.0, which is what Roslyn loads analyzers on, so it works in a consumer's compiler whatever that consumer targets; the generated code is emitted in C# 14 and compiles on both.

SharpPortico

Highlights

  • πŸš€ IIncrementalGenerator β€” fast incremental pipeline, safe on 10k-line specs
  • πŸ“¦ C# 14 output β€” primary constructors, collection expressions, required members, file-scoped namespaces
  • 🧱 NativeAOT / reflection-free β€” hand-written IMessage<T> implementations, no Activator; samples/NativeAotExample is a 3.1 contract published as a native image and run in CI, so the claim is a binary that runs rather than a flag
  • 🌐 Both server and client, and both hosts β€” the generated ServiceBase is served by Grpc.Core or by ASP.NET Core's MapGrpcService, alongside a modern typed client (GrpcChannel)
  • πŸ” Proxy mode β€” generated {Service}Proxy : ServiceBase forwards gRPC β†’ legacy REST (X-Api-Key outbound, response cache with per-call bypass, ULID client keys, audit logging)
  • πŸ” Auth mapped β€” Bearer / API-Key / OAuth2 metadata helpers and interceptors
  • 🧠 Smart mapping β€” $ref, allOf, oneOf/anyOf, arrays β†’ repeated, enums, pagination detection, streaming hints (x-grpc-streaming), octet-stream β†’ bytes
  • 🎯 Presence, not defaults β€” a nullable scalar becomes a proto3 optional field with Has…/Clear…, so "never set" and "set to 0" stay different answers on the wire, in Equals, and in the gateway (presence)
  • 🧾 A CLI that answers a gate β€” sharpportico generate spec.yaml --format json prints one deterministic document (service identity, RPC surface, counts, every diagnostic) and still exits 1 on a refusal, so a pipeline branches on the exit code instead of parsing text
  • πŸ› οΈ Two declaration styles β€” <AdditionalFiles> and/or [OpenApiToGrpc] assembly attributes

Architecture

flowchart LR
    A[openapi.yaml / .json] -->|AdditionalFiles| G[SharpPorticoGenerator<br/>IIncrementalGenerator]
    A -->|OpenApiToGrpc attribute| G
    G -->|Microsoft.OpenApi| P[Parser + SchemaMapper<br/>OpenAPI 3.0/3.1]
    P -->|GrpcModel IR| E[Emitters]
    E --> C[Generated C# .g.cs<br/>messages + service + client + DI]
    E --> PR[Generated .proto.cs<br/>proto descriptor]
    E --> PX["Generated {Service}Proxy<br/>gRPC to REST gateway"]
    C --> R[Google.Protobuf]
    C --> S[Grpc.Core / Grpc.Net.Client]
    PX --> H[HttpRestClient + IProxyCache + IKeyProvider]
Loading

Mapping rules

OpenAPI Concept gRPC / Protobuf Mapping
paths + HTTP verb Unary RPC by default; x-grpc-streaming or large POST payloads β†’ streaming
path / query / header params Combined into a single *Request message
request body Nested message; application/octet-stream β†’ bytes
response *Response message + google.rpc.Status-shaped error wrapper
components/schemas message definitions; $ref, allOf, oneOf/anyOf resolved
OpenAPI 3.1 β€” type arrays, const, prefixItems, contentEncoding, numeric exclusive bounds, $defs, webhooks read through a 3.0 rewrite; each construct is mapped, and what it lost is reported (SP1002/SP1003) rather than passed over
arrays repeated fields
nullable: true (or a 3.1 ["string", "null"] type array) optional on a singular scalar: proto3 presence, so a value set to the default and a value never sent stay different answers
enums protobuf enums
authentication metadata helpers + interceptors for Bearer / API-Key / OAuth2
pagination page/limit/cursor/next_page_token detected β†’ token-based or server-streaming

Quick start

1. Declare the spec

OpenApiToGrpc attribute (assembly level):

using SharpPortico;

[assembly: OpenApiToGrpc("openapi/users.yaml", "UserService", "MyApp.Generated")]

or MSBuild <AdditionalFiles>:

<ItemGroup>
  <AdditionalFiles Include="openapi/**/*.yaml" />
</ItemGroup>

2. Reference the generator

<ProjectReference Include="../../src/SharpPortico.SourceGenerator/SharpPortico.SourceGenerator.csproj"
                  ReferenceOutputAssembly="false"
                  OutputItemType="Analyzer" />

3. Use the generated code

using var channel = GrpcChannel.ForAddress("http://localhost:50051");
var client = UserServiceClient.Create(channel);

var user = await client.GetUserAsync(42);

Hosting the generated service

One service base, two hosts.

ASP.NET Core (grpc-dotnet). The base carries [BindServiceMethod(typeof(PetService), "BindService")] and the contract carries the BindService(ServiceBinderBase, PetServiceBase) overload that grpc-dotnet's binder looks for, so MapGrpcService finds the service:

builder.Services.AddGrpc();
builder.Services.AddSingleton<PetServiceImpl>();

var app = builder.Build();
app.MapGrpcService<PetServiceImpl>();

Grpc.Core. Unchanged: PetService.BindService(new PetServiceImpl()) returns the ServerServiceDefinition that Server takes.

An implementation overrides ListPetsAsync(request, context) either way. grpc-dotnet binds a handler by the RPC's own name, so the base also declares ListPets(request, context), which forwards to ListPetsAsync.

NativeAOT: the messages, the contract and the client are reflection-free, but grpc-dotnet's server binds by reflection β€” it looks up the binder method and each handler at startup β€” so an ASP.NET Core gRPC server is not an AOT target. That is a property of grpc-dotnet rather than of the generated code.

The Grpc.Core host above is the AOT-safe server, which is what samples/NativeAotExample publishes: a native executable that serves the generated contract and drives the generated client from the same process.

Proxy mode (gRPC clients β†’ legacy REST)

Point local .NET apps at SharpPortico over gRPC while it forwards to a legacy REST service (X-Api-Key, corporate network). Host the generated {Service}Proxy : ServiceBase:

[assembly: OpenApiToGrpc("openapi/users.yaml", "UserService", "MyApp.Generated",
    EnableProxyGeneration = true,
    ProxyBaseUrl = "https://corporate.example",
    ProxyApiKeyHeaderName = "X-Api-Key",
    ProxyCacheTtlSeconds = 60,
    ProxyClientKeyMode = ClientKeyMode.None,
    ProxyAuditEnabled = true)]
var options = new ProxyOptions { BaseUrl = "https://corporate.example", ApiKeyHeaderName = "X-Api-Key" };
server.Services.Add(UserService.BindService(
    new UserServiceProxy(options, new HttpRestClient(httpClient, options.BaseUrl),
        cache: new MemoryProxyCache(memoryCache), keys: keyProvider)));
  • Cache: GET responses are cached (TTL). Clients bypass per call via x-portico-bypass-cache metadata.
  • Client keys (ProxyClientKeyMode): None Β· Forward (client key β†’ X-Api-Key 1:1) Β· Own (validate ULID-shaped key x-portico-key, use configured outbound key).
  • Keys never hardcoded: IKeyProvider (config / Key Vault / delegate).
  • Audit: ProxyAuditEnabled = true (or inject IProxyAuditLogger) logs client, RPC, cache-hit, HTTP status.

Live end-to-end demo: samples/LegacyProxyExample. OpenAPI 3.1 + NativeAOT demo: samples/NativeAotExample.

Documentation

Document Answers
docs/README.md the index: which page answers which question, and how each claim is proved
docs/SharpPortico.md the developer guide β€” packages, declaration, proxy mode, mapping rules, samples
docs/reference.md every option and default, the generated surface member by member, the runtime contracts, the CLI, compatibility
docs/presence.md nullable fields β†’ proto3 optional: members, wire format, gateway behaviour, upgrading
docs/diagnostics.md every SP… id, its severity, what to fix, and how to gate a build on it
src/*/README.md the NuGet package readmes, shipped inside the packages

Repo layout

SharpPortico/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ SharpPortico.SourceGenerator/   // Incremental generator
β”‚   β”œβ”€β”€ SharpPortico.Abstractions/      // [OpenApiToGrpc] attribute + options
β”‚   β”œβ”€β”€ SharpPortico.Runtime/           // AOT-safe runtime helpers (proxy pipeline; packed)
β”‚   └── SharpPortico.Cli/               // dotnet sharpportico generate (tool)
β”œβ”€β”€ samples/
β”‚   β”œβ”€β”€ GrpcServerExample/              // full gRPC server + client (petstore)
β”‚   β”œβ”€β”€ MinimalApiExample/              // ASP.NET Minimal API over the gRPC client
β”‚   β”œβ”€β”€ LegacyProxyExample/             // gRPCβ†’REST proxy: cache hit + bypass demo
β”‚   └── NativeAotExample/               // OpenAPI 3.1 contract published as a native image (PublishAot)
β”œβ”€β”€ tests/
β”‚   └── SharpPortico.Tests/             // xunit snapshot + mapping + perf tests
β”œβ”€β”€ docs/
└── .github/scripts/check-protos.sh     // compiles every sample's emitted descriptor with protoc

CLI

dotnet tool install -g SharpPortico.Cli --version 1.5.0

# What the mapping makes of the spec: service, namespace, package, RPC surface, counts, diagnostics
sharpportico generate openapi/petstore.yaml

# The same answer as one document, for a CI job that must not parse text
sharpportico generate openapi/petstore.yaml --format json

# And the descriptor a build of it would ship
sharpportico generate openapi/petstore.yaml --out out/

The descriptor comes from the emitter the generator itself calls and is LF-normalized exactly as the generated {Service}Proto.Text const holds it, so the file and a build of the same spec cannot disagree β€” which is what makes it worth handing to protoc, to a client generator or to a reviewer. The C# compiler never reads that descriptor, so CI compiles every sample's with protoc (.github/scripts/check-protos.sh): a name protoc rejects, or an import left out of the file, is otherwise a build that stays green.

--format json puts one document on stdout and nothing else β€” formatVersion, the spec path as given, the service identity, the RPC surface, the counts and every diagnostic with its id, severity and message. A refused contract still produces the document and still exits 1, so the exit code is the first gate and the document is the second:

sharpportico generate openapi/petstore.yaml --format json > report.json
jq -e '[.diagnostics[] | select(.severity == "error")] | length == 0' report.json

The bytes are deterministic β€” UTF-8 without a byte-order mark, \n line endings, one trailing newline β€” and identical between runs and between operating systems, which is what makes the format worth diffing. tests/SharpPortico.Tests/openapi/petstore.report.json pins it and a Linux CI step regenerates and diffs it. The developer guide lists every member and shows a refusal.

JavaPortico

Looking for the Java equivalent? JavaPortico is the Java sibling of SharpPortico β€” the same OpenAPI β†’ gRPC + protobuf generator and gRPC↔REST proxy pipeline for the Java/Maven ecosystem (JDK 25 LTS), with a matching mapping model, proxy runtime and configuration knobs.

Changelog

See CHANGELOG.md for the full release history.

License

MIT β€” see LICENSE. Copyright (c) 2026 MPCoreDeveloper.

About

SharpPortico - Incremental Source Generator for OpenAPI to gRPC (C# 14 /15 .NET 10/11)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages