Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```
103 changes: 103 additions & 0 deletions examples/audience_example.yml
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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
Expand All @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions src/openapi/interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =
Expand Down Expand Up @@ -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))))
Expand Down Expand Up @@ -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<Vec<String>> {
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,
Expand Down
29 changes: 27 additions & 2 deletions src/openapi/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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(),
})
Expand Down Expand Up @@ -209,6 +226,13 @@ struct Operation {
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
responses: BTreeMap<u16, RefOr<ResponseBody>>,

/// Audiences this operation is intended for.
#[serde(
rename = "x-openapi-interfaces-audience",
skip_serializing_if = "Option::is_none"
)]
audience: Option<Vec<String>>,

/// YAML fields we want to pass through blindly.
#[serde(flatten)]
unknown_fields: BTreeMap<String, Value>,
Expand All @@ -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(),
})
}
Expand Down
4 changes: 2 additions & 2 deletions src/openapi/ref_or.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<D, _>(
"$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::<D, _>(
"$interface schema",
Value::Mapping(yaml),
Expand Down
Loading
Loading