Skip to content
Matthew Adams By Matthew Adams Co-Founder · 4 min read
OpenAPI Code Generation with Corvus: Filtering

At endjin, we maintain Corvus.JsonSchema, and in the previous post we looked at callbacks, webhooks, and links.

Now let's talk about a problem that surfaces the moment you try to use code generation against a real-world API: the spec is enormous, and you only need a fraction of it.

The large-spec problem

A petstore demo has five endpoints. The real world has Stripe (300+ endpoints), Azure Resource Manager (thousands), or your organisation's internal platform API that has grown organically over several years. Generating a typed client for the entire spec produces a codebase you'll struggle to navigate, compile times you'll notice, and a dependency surface you can't reason about.

More importantly, it undermines one of the key benefits of code generation in the first place. If your payment processing service has a typed client for every endpoint on the platform, including user management, analytics, and admin operations it will never call, then the generated code obscures rather than clarifies what the service actually depends on.

What you want is a focused client that contains exactly the operations your service uses, and nothing else. The generated types then serve as documentation of your service's external dependencies, and the compiler tells you when those dependencies change.

Path filtering

The --include-path and --exclude-path options let you select which operations to generate. They use glob patterns against the URL path:

# Generate only the /pets endpoints (including nested paths like /pets/{petId})
corvusjson openapi-client petstore.json --include-path "/pets/**"

# Generate everything except admin endpoints
corvusjson openapi-client petstore.json --exclude-path "/admin/**"

# Include /pets and /store, but exclude store admin
corvusjson openapi-client petstore.json \
    --include-path "/pets/**" --include-path "/store/**" \
    --exclude-path "/store/admin/**"

The patterns work intuitively: * matches one path segment, ** matches any depth. Include patterns are additive (a union), and exclude patterns subtract from that set. If you don't specify any includes, everything is included by default. You can therefore use --exclude-path on its own to remove just the parts you don't want.

One important design choice: filtering operates at the operation level, not the schema level. If a filtered operation references a shared model type (say, an Address schema used by both /customers and /orders), that model is still generated even if you only included /customers. This means filtered clients are always self-contained. They compile without needing the rest of the spec's types.

Previewing before generating

Generating code and then discovering you got the filter wrong is frustrating. The openapi-show command lets you preview the operation tree that a given filter will produce:

# See everything in the spec
corvusjson openapi-show petstore.json --group-by tag

# Preview what your filter selects
corvusjson openapi-show petstore.json --include-path "/pets/**"

The output looks like this:

Petstore v3.1.0 (OpenAPI 3.1)

Operations (4 of 12)
├── /pets
│   ├── GET listPets - List all pets (paginated)
│   └── POST createPet - Create a pet
├── /pets/{petId}
│   ├── GET showPetById - Info for a specific pet
│   └── DELETE deletePet - Delete a specific pet

Once you're happy with the selection, apply the same filter to openapi-client or openapi-server. This preview-then-generate workflow avoids wasted regeneration cycles.

Domain-specific clients from a single spec

The real power of filtering emerges when you combine it with separate output namespaces. Consider Stripe's 300+ endpoint spec. Rather than generating one monolithic client, you can carve it into focused domain clients:

# Payments domain
corvusjson openapi-client spec3.json \
    --include-path "/v1/payment_intents/**,/v1/charges/**,/v1/refunds/**" \
    --rootNamespace MyApp.Stripe.Payments \
    --outputPath ./Generated/Payments

# Customer domain
corvusjson openapi-client spec3.json \
    --include-path "/v1/customers/**,/v1/subscriptions/**" \
    --rootNamespace MyApp.Stripe.Customers \
    --outputPath ./Generated/Customers

# Billing domain
corvusjson openapi-client spec3.json \
    --include-path "/v1/invoices/**,/v1/plans/**,/v1/prices/**" \
    --rootNamespace MyApp.Stripe.Billing \
    --outputPath ./Generated/Billing

Each invocation produces a self-contained client with its own namespace, its own set of generated types, and its own lock file. Your payment service references only MyApp.Stripe.Payments. Your subscription management service references only MyApp.Stripe.Customers. The dependency boundaries are explicit in the project references.

This maps naturally to bounded context boundaries if you're following domain-driven design principles. Each context gets exactly the external API surface it needs, and regeneration in one context doesn't affect the others.

Server-side filtering

The same filtering applies to server stubs. If your service implements only a subset of a larger shared spec, perhaps because you're responsible for the /inventory endpoints in a platform that also defines /orders and /shipping, you generate stubs for just your portion:

corvusjson openapi-server platform-api.json \
    --include-path "/inventory/**" \
    --rootNamespace Inventory.Api \
    --outputPath ./Generated

This is especially useful in microservice architectures where multiple teams contribute operations to a shared OpenAPI document. Each team generates server stubs for their own endpoints, and the shared spec remains the single source of truth for cross-team contract validation.

Combining filters with the lock file

The lock file (covered in the OpenAPI documentation) records the filter patterns alongside the spec hash. This means that re-running the generator without --force will correctly skip regeneration only if both the spec content and the filter patterns are unchanged. If you change your --include-path, the generator detects the mismatch and regenerates.

This also means you can commit the lock file and use it as documentation of exactly which operations each project depends on. A colleague looking at the lock file can see at a glance that this project generates from paths matching /v1/payment_intents/** and nothing else.

When to filter

Filtering isn't something you need for a small spec with ten endpoints. But it becomes valuable quickly:

  • Large third-party APIs (Stripe, GitHub, Azure) where you use a tiny fraction of the surface area
  • Shared organisational specs where multiple teams own different path prefixes
  • Incremental adoption where you're migrating to generated clients one domain at a time and want to avoid a big-bang regeneration
  • Compile-time isolation where you want each microservice's generated code to reflect only its actual dependencies

For the complete filtering reference (including the --tag option for filtering by operation tags), see the OpenAPI documentation on corvus-oss.org.

In the next post, we'll look at testing - unit testing your handler logic in isolation, and integration testing the full client-to-server round-trip.

FAQs

Can I generate a client for just a subset of a large OpenAPI spec? Yes. Use --include-path with glob patterns to select the operations you want. For example, --include-path '/v1/payments/**' generates only the payment endpoints. Model types referenced by those operations are included automatically.
How do I preview which operations a filter will select before generating code? Run corvusjson openapi-show with the same --include-path or --exclude-path flags. It displays the operation tree that would be generated, without producing any code.
Can I split one spec into multiple domain-specific clients? Yes. Run the generator multiple times with different --include-path patterns and --rootNamespace values. Each invocation produces an independent client (or server stub) containing only the operations and models relevant to that domain.

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.