From 3e0c8b84f20cea121fdaa3a1699399db27c0804b Mon Sep 17 00:00:00 2001 From: zebapy Date: Tue, 25 Nov 2025 10:17:48 -0500 Subject: [PATCH 1/6] Add audience filtering support with x-openapi-interfaces-audience extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for filtering OpenAPI specs by audience, similar to Fern's x-fern-audiences extension. The implementation allows API definitions to be tagged at the endpoint and field level, then filtered during build time. Features: - Add --audience flag to filter by one or more audiences - Add --audience-mode flag to control include/exclude behavior - Support x-openapi-interfaces-audience at operation level (endpoints) - Support x-openapi-interfaces-audience at schema property level (fields) - Items without audiences are treated as public/default and included by default - Add comprehensive example demonstrating audience filtering Implementation details: - Extend Scope struct with audience filtering configuration - Add AudienceFilterMode enum (Include/Exclude) - Filter operations during OpenApi transpilation - Filter interface members during Interface transpilation - Filter schema properties during PrimitiveSchema transpilation - Preserve required field list consistency when filtering properties 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 50 +++++++++++++++++ examples/audience_example.yml | 103 ++++++++++++++++++++++++++++++++++ src/main.rs | 29 ++++++++++ src/openapi/interface.rs | 14 +++++ src/openapi/mod.rs | 29 +++++++++- src/openapi/schema.rs | 39 ++++++++++++- src/openapi/transpile.rs | 52 +++++++++++++++++ 7 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 examples/audience_example.yml diff --git a/README.md b/README.md index bdc90c6..d5530f8 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ We will provide binaries at some point. ```sh openapi-interfaces --help openapi-interfaces api_with_interfaces.yml -o api.yml + +# Filter by audience +openapi-interfaces api_with_interfaces.yml -o api.yml --audience beta +openapi-interfaces api_with_interfaces.yml -o api.yml --audience beta --audience-mode exclude ``` ## OpenAPI extensions @@ -121,3 +125,49 @@ Possible options are: - `Widget#Put`: The type passed to a `PUT` request. May also be used as the base type for applying `#MergePatch` values. - `Widget#MergePatch`: A [JSON Merge Patch](https://datatracker.ietf.org/doc/html/rfc7396) schema that can be passed to `PATCH`. - `Widget#SameAsInterface`: **May only be used inside `components.interfaces`.** This is a shortcut value that says "When generating a `#Get` interface, include `$ref: "components.schemas.Widget`. When generating a `#Post` interface, include `$ref: "components.schemas.WidgetPost`." In other words, use the same variant selector as the containing interface. Useful for when compound types are sent over the wire. + +### Audience Filtering + +This tool supports the `x-openapi-interfaces-audience` extension to filter API definitions for different audiences (e.g., public, beta, internal). You can apply audiences to: + +- **Operations/Endpoints**: Tag entire endpoints with specific audiences +- **Schema Properties**: Tag individual fields within interface members + +Items without an audience tag are considered public and are included in all filtered outputs. + +Example: + +```yaml +paths: + /beta-endpoint: + get: + x-openapi-interfaces-audience: ["beta"] + responses: + 200: + description: Beta endpoint + +components: + interfaces: + Widget: + members: + publicField: + schema: + type: string + betaField: + schema: + type: string + x-openapi-interfaces-audience: ["beta"] +``` + +Use the `--audience` flag to filter the output: + +```sh +# Include only beta and public items +openapi-interfaces api.yml -o api_beta.yml --audience beta + +# Exclude beta items (show only public and other audiences) +openapi-interfaces api.yml -o api_public.yml --audience beta --audience-mode exclude + +# Include multiple audiences +openapi-interfaces api.yml -o api.yml --audience beta --audience internal +``` diff --git a/examples/audience_example.yml b/examples/audience_example.yml new file mode 100644 index 0000000..feef633 --- /dev/null +++ b/examples/audience_example.yml @@ -0,0 +1,103 @@ +# Example demonstrating x-openapi-interfaces-audience extension + +openapi: "3.1.0" +info: + title: Example API with Audiences + version: "1.0.0" + +paths: + /public-endpoint: + get: + summary: Public endpoint available to everyone + responses: + 200: + description: Success + content: + application/json: + schema: + $interface: "PublicWidget" + + /beta-endpoint: + get: + summary: Beta endpoint only for beta users + x-openapi-interfaces-audience: ["beta"] + responses: + 200: + description: Success + content: + application/json: + schema: + $interface: "BetaWidget" + + /internal-endpoint: + post: + summary: Internal endpoint for internal use only + x-openapi-interfaces-audience: ["internal"] + requestBody: + required: true + content: + application/json: + schema: + $interface: "InternalWidget#Post" + responses: + 201: + description: Created + content: + application/json: + schema: + $interface: "InternalWidget" + +components: + interfaces: + PublicWidget: + description: Widget with public and beta fields + members: + id: + required: true + schema: + type: string + format: uuid + name: + required: true + mutable: true + schema: + type: string + publicField: + required: true + schema: + type: string + betaField: + mutable: true + schema: + type: string + x-openapi-interfaces-audience: ["beta"] + internalField: + mutable: true + schema: + type: string + x-openapi-interfaces-audience: ["internal"] + + BetaWidget: + description: Widget for beta users + members: + id: + required: true + schema: + type: string + betaOnlyField: + required: true + schema: + type: string + + InternalWidget: + description: Widget for internal use + members: + id: + required: true + schema: + type: string + internalData: + required: true + mutable: true + schema: + type: object diff --git a/src/main.rs b/src/main.rs index 5d8b54a..4941732 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,16 @@ struct Opt { /// true` and set `openapi: "3.0.0"` in the output. #[structopt(long = "use-nullable-for-merge-patch", alias = "avoid-type-null")] use_nullable_for_merge_patch: bool, + + /// Filter by audience(s). Can be specified multiple times. + #[structopt(long = "audience")] + audiences: Vec, + + /// How to filter audiences: "include" or "exclude". + /// "include" means only items with matching audiences are included. + /// "exclude" means items with matching audiences are excluded. + #[structopt(long = "audience-mode", default_value = "include")] + audience_mode: String, } /// Main entry point. Really just a wrapper for `run` which sets up logging and @@ -54,12 +64,31 @@ fn main() { /// Our real entry point. fn run(opt: &Opt) -> Result<()> { + // Validate audience_mode + if opt.audience_mode != "include" && opt.audience_mode != "exclude" { + return Err(anyhow::format_err!( + "audience-mode must be either 'include' or 'exclude', got '{}'", + opt.audience_mode + )); + } + let mut openapi = OpenApi::from_path(&opt.input)?; let mut scope = Scope::default(); if !openapi.supports_type_null() || opt.use_nullable_for_merge_patch { // We don't support `type: "null"`, so don't introduce it. scope.use_nullable_for_merge_patch = true; } + + // Set audience filtering configuration + if !opt.audiences.is_empty() { + scope.filter_audiences = Some(opt.audiences.clone()); + scope.audience_filter_mode = if opt.audience_mode == "include" { + openapi::AudienceFilterMode::Include + } else { + openapi::AudienceFilterMode::Exclude + }; + } + trace!("Parsed: {:#?}", openapi); resolve_included_files(&mut openapi, &opt.input)?; trace!("With includes: {:#?}", openapi); diff --git a/src/openapi/interface.rs b/src/openapi/interface.rs index 4b120cd..01506aa 100644 --- a/src/openapi/interface.rs +++ b/src/openapi/interface.rs @@ -458,6 +458,11 @@ impl TranspileInterface for BasicInterface { let mut required = vec![]; let mut properties = BTreeMap::new(); for (name, member) in &self.members { + // Filter members based on audience + if !scope.should_include_item(&member.get_audience()) { + continue; + } + let is_discriminator = Some(name) == self.discriminator_member_name.as_ref(); if let Some(schema) = @@ -533,6 +538,7 @@ impl TranspileInterface for BasicInterface { title, r#const: None, example, + audience: None, unknown_fields: BTreeMap::default(), }; Ok(RefOr::Value(BasicSchema::Primitive(Box::new(schema)))) @@ -567,6 +573,14 @@ impl Member { self.initializable.unwrap_or(self.mutable) } + /// Get the audience from this member's schema, if any. + fn get_audience(&self) -> Option> { + match &self.schema { + RefOr::Value(BasicSchema::Primitive(prim)) => prim.audience.clone(), + _ => None, + } + } + /// Should this member be marked as `required` in this variant? fn is_required_for( &self, diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs index 6a76ade..77c219c 100644 --- a/src/openapi/mod.rs +++ b/src/openapi/mod.rs @@ -29,7 +29,7 @@ use crate::parse_error::{Annotation, FileInfo, ParseError}; use self::interface::Interfaces; use self::ref_or::{ExpectedWhenParsing, RefOr}; use self::schema::Schema; -pub use self::transpile::{Scope, Transpile}; +pub use self::transpile::{AudienceFilterMode, Scope, Transpile}; /// An OpenAPI file, with our extensions. #[serde_as] @@ -120,10 +120,27 @@ impl Transpile for OpenApi { } else { self.openapi.clone() }; + + // Transpile paths with audience filtering + let mut paths = BTreeMap::new(); + for (path, methods) in &self.paths { + let mut filtered_methods = BTreeMap::new(); + for (method, operation) in methods { + // Filter operations based on audience + if scope.should_include_item(&operation.audience) { + filtered_methods.insert(*method, operation.transpile(scope)?); + } + } + // Only include the path if it has at least one method + if !filtered_methods.is_empty() { + paths.insert(path.clone(), filtered_methods); + } + } + Ok(Self { openapi, include_files: Default::default(), - paths: self.paths.transpile(scope)?, + paths, components: self.components.transpile(scope)?, unknown_fields: self.unknown_fields.clone(), }) @@ -209,6 +226,13 @@ struct Operation { #[serde(skip_serializing_if = "BTreeMap::is_empty")] responses: BTreeMap>, + /// Audiences this operation is intended for. + #[serde( + rename = "x-openapi-interfaces-audience", + skip_serializing_if = "Option::is_none" + )] + audience: Option>, + /// YAML fields we want to pass through blindly. #[serde(flatten)] unknown_fields: BTreeMap, @@ -221,6 +245,7 @@ impl Transpile for Operation { Ok(Self { request_body: self.request_body.transpile(scope)?, responses: self.responses.transpile(scope)?, + audience: self.audience.clone(), unknown_fields: self.unknown_fields.clone(), }) } diff --git a/src/openapi/schema.rs b/src/openapi/schema.rs index a7285d6..e795498 100644 --- a/src/openapi/schema.rs +++ b/src/openapi/schema.rs @@ -501,6 +501,14 @@ pub struct PrimitiveSchema { #[serde(default, skip_serializing_if = "Option::is_none")] pub example: Option, + /// Audiences this schema is intended for. + #[serde( + rename = "x-openapi-interfaces-audience", + default, + skip_serializing_if = "Option::is_none" + )] + pub audience: Option>, + /// YAML fields we want to pass through blindly. #[serde(flatten)] pub unknown_fields: BTreeMap, @@ -530,6 +538,7 @@ impl PrimitiveSchema { title: Default::default(), r#const: Default::default(), example: Default::default(), + audience: Default::default(), unknown_fields: Default::default(), } } @@ -573,10 +582,35 @@ impl Transpile for PrimitiveSchema { types.insert(Type::Null); } + // Filter properties based on audience + let mut properties = BTreeMap::new(); + for (name, schema) in &self.properties { + // Check if this property should be included based on its audience + // We need to check BEFORE transpilation to get the original audience + let should_include = match schema { + RefOr::Value(BasicSchema::Primitive(prim)) => { + scope.should_include_item(&prim.audience) + } + _ => true, // Include refs and non-primitive schemas by default + }; + if should_include { + let transpiled = schema.transpile(scope)?; + properties.insert(name.clone(), transpiled); + } + } + + // Filter required fields to only include properties that are still present + let required: Vec = self + .required + .iter() + .filter(|name| properties.contains_key(*name)) + .cloned() + .collect(); + Ok(Self { types, - required: self.required.clone(), - properties: self.properties.transpile(scope)?, + required, + properties, additional_properties: self.additional_properties.transpile(scope)?, items: self.items.transpile(scope)?, nullable: None, @@ -584,6 +618,7 @@ impl Transpile for PrimitiveSchema { title: self.title.clone(), r#const: self.r#const.clone(), example: self.example.clone(), + audience: self.audience.clone(), unknown_fields: self.unknown_fields.clone(), }) } diff --git a/src/openapi/transpile.rs b/src/openapi/transpile.rs index a2ed9eb..db2e989 100644 --- a/src/openapi/transpile.rs +++ b/src/openapi/transpile.rs @@ -6,6 +6,21 @@ use anyhow::Result; use super::interface::InterfaceVariant; +/// Audience filtering mode. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AudienceFilterMode { + /// Include only items with matching audiences. + Include, + /// Exclude items with matching audiences. + Exclude, +} + +impl Default for AudienceFilterMode { + fn default() -> Self { + AudienceFilterMode::Include + } +} + /// Current transpilation scope. Contains state that applies to everything /// within a block, recursively. Think of this in terms of "local variable /// scope" in a compiler for a regular programming language. @@ -17,6 +32,12 @@ pub struct Scope { /// The `InterfaceVariant` of the containing interface. pub variant: Option, + + /// Audiences to filter by. + pub filter_audiences: Option>, + + /// How to filter audiences. + pub audience_filter_mode: AudienceFilterMode, } impl Scope { @@ -26,6 +47,35 @@ impl Scope { Self { use_nullable_for_merge_patch: self.use_nullable_for_merge_patch, variant: Some(variant), + filter_audiences: self.filter_audiences.clone(), + audience_filter_mode: self.audience_filter_mode.clone(), + } + } + + /// Check if an item should be included based on its audiences. + /// Returns true if the item should be included, false if it should be filtered out. + pub fn should_include_item(&self, item_audiences: &Option>) -> bool { + // If no filter is configured, include everything + let Some(ref filter_audiences) = self.filter_audiences else { + return true; + }; + + // If item has no audiences, it's considered public/default + let Some(ref audiences) = item_audiences else { + // Items without audiences are always included + // - In include mode: they're public/default, so include them + // - In exclude mode: they don't have the excluded audience, so include them + return true; + }; + + // Check if there's any overlap between filter audiences and item audiences + let has_match = audiences + .iter() + .any(|aud| filter_audiences.contains(aud)); + + match self.audience_filter_mode { + AudienceFilterMode::Include => has_match, + AudienceFilterMode::Exclude => !has_match, } } } @@ -36,6 +86,8 @@ impl Default for Scope { Self { use_nullable_for_merge_patch: false, variant: None, + filter_audiences: None, + audience_filter_mode: AudienceFilterMode::default(), } } } From 743521b57da5df099899d26b12ef9627913db523 Mon Sep 17 00:00:00 2001 From: zebapy Date: Tue, 25 Nov 2025 10:22:26 -0500 Subject: [PATCH 2/6] Update transpile.rs --- src/openapi/transpile.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/openapi/transpile.rs b/src/openapi/transpile.rs index db2e989..b64f9f8 100644 --- a/src/openapi/transpile.rs +++ b/src/openapi/transpile.rs @@ -69,9 +69,7 @@ impl Scope { }; // Check if there's any overlap between filter audiences and item audiences - let has_match = audiences - .iter() - .any(|aud| filter_audiences.contains(aud)); + let has_match = audiences.iter().any(|aud| filter_audiences.contains(aud)); match self.audience_filter_mode { AudienceFilterMode::Include => has_match, From db906d1d023abed5fce0d3a6d47eb51a2705c9ec Mon Sep 17 00:00:00 2001 From: zebapy Date: Tue, 25 Nov 2025 10:43:37 -0500 Subject: [PATCH 3/6] Fix clippy warnings - Derive Default for AudienceFilterMode instead of manual impl - Remove needless borrows in ref_or.rs and schema.rs --- src/openapi/ref_or.rs | 4 ++-- src/openapi/schema.rs | 6 +++--- src/openapi/transpile.rs | 9 ++------- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/openapi/ref_or.rs b/src/openapi/ref_or.rs index 1f2c4df..362b604 100644 --- a/src/openapi/ref_or.rs +++ b/src/openapi/ref_or.rs @@ -62,12 +62,12 @@ where let yaml_str = |s| Value::String(String::from(s)); // Look for `$includes` or `$ref`, otherwise fall back. - if yaml.contains_key(&yaml_str("$ref")) { + if yaml.contains_key(yaml_str("$ref")) { Ok(RefOr::Ref(deserialize_enum_helper::( "$ref schema", Value::Mapping(yaml), )?)) - } else if yaml.contains_key(&yaml_str("$interface")) { + } else if yaml.contains_key(yaml_str("$interface")) { Ok(RefOr::InterfaceRef(deserialize_enum_helper::( "$interface schema", Value::Mapping(yaml), diff --git a/src/openapi/schema.rs b/src/openapi/schema.rs index e795498..52b306a 100644 --- a/src/openapi/schema.rs +++ b/src/openapi/schema.rs @@ -179,17 +179,17 @@ impl<'de> Deserialize<'de> for BasicSchema { let yaml_str = |s| Value::String(String::from(s)); // Look for `$includes`. - if yaml.contains_key(&yaml_str("allOf")) { + if yaml.contains_key(yaml_str("allOf")) { Ok(BasicSchema::AllOf(deserialize_enum_helper::( "allOf schema", Value::Mapping(yaml), )?)) - } else if yaml.contains_key(&yaml_str("oneOf")) { + } else if yaml.contains_key(yaml_str("oneOf")) { Ok(BasicSchema::OneOf(deserialize_enum_helper::( "oneOf schema", Value::Mapping(yaml), )?)) - } else if yaml.contains_key(&yaml_str("type")) { + } else if yaml.contains_key(yaml_str("type")) { Ok(BasicSchema::Primitive(deserialize_enum_helper::( "schema", Value::Mapping(yaml), diff --git a/src/openapi/transpile.rs b/src/openapi/transpile.rs index b64f9f8..85cd408 100644 --- a/src/openapi/transpile.rs +++ b/src/openapi/transpile.rs @@ -7,20 +7,15 @@ use anyhow::Result; use super::interface::InterfaceVariant; /// Audience filtering mode. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub enum AudienceFilterMode { /// Include only items with matching audiences. + #[default] Include, /// Exclude items with matching audiences. Exclude, } -impl Default for AudienceFilterMode { - fn default() -> Self { - AudienceFilterMode::Include - } -} - /// Current transpilation scope. Contains state that applies to everything /// within a block, recursively. Think of this in terms of "local variable /// scope" in a compiler for a regular programming language. From 1a552fb1873514831c03654c6207f5a533e378ac Mon Sep 17 00:00:00 2001 From: zebapy Date: Tue, 25 Nov 2025 10:55:17 -0500 Subject: [PATCH 4/6] Trigger CI re-run From a458c3f9148f8b4174de3bdfce98fd071b9b90e6 Mon Sep 17 00:00:00 2001 From: zebapy Date: Tue, 25 Nov 2025 11:02:33 -0500 Subject: [PATCH 5/6] ci From a42061d4e39b4f0a4ce63fe1ca23705c7d5ae0cf Mon Sep 17 00:00:00 2001 From: zebapy Date: Tue, 25 Nov 2025 11:14:37 -0500 Subject: [PATCH 6/6] Trigger CI re-run