Skip to content

Commit b4ba551

Browse files
authored
feat: output filtering (#25)
1 parent 13b12ef commit b4ba551

8 files changed

Lines changed: 479 additions & 1 deletion

File tree

crates/oapi-codegen/src/config.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,25 @@ pub struct OutputOptions {
5656
/// Keep schemas that are not referenced (no pruning).
5757
#[serde(default)]
5858
pub skip_prune: bool,
59+
/// Only generate operations tagged with one of these tags (empty = all).
60+
#[serde(default)]
61+
pub include_tags: Vec<String>,
62+
/// Skip operations tagged with any of these tags.
63+
#[serde(default)]
64+
pub exclude_tags: Vec<String>,
65+
/// Only generate operations whose `operationId` is one of these
66+
/// (empty = all).
67+
#[serde(default)]
68+
pub include_operation_ids: Vec<String>,
69+
/// Skip operations whose `operationId` is one of these.
70+
#[serde(default)]
71+
pub exclude_operation_ids: Vec<String>,
72+
/// Remove these component schemas from the spec before lowering, so their
73+
/// models are not generated. Filtering runs before pruning; if an excluded
74+
/// schema is still referenced by a retained operation or schema, generation
75+
/// may fail or emit a reference to a type that is not declared.
76+
#[serde(default)]
77+
pub exclude_schemas: Vec<String>,
5978
}
6079

6180
impl Config {

crates/oapi-codegen/src/filter.rs

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//! Spec-level operation and schema filtering.
2+
//!
3+
//! Mirrors `oapi-codegen`'s `output-options` filters: operations are dropped
4+
//! when they fail any active include/exclude **tag** or **operation-id** filter,
5+
//! and component schemas named in `exclude-schemas` are removed before lowering.
6+
//! Filtering runs before pruning, so removing an operation lets the prune pass
7+
//! drop any component schemas it uniquely referenced.
8+
9+
use openapiv3::OpenAPI;
10+
use openapiv3::Operation;
11+
use openapiv3::PathItem;
12+
use openapiv3::ReferenceOr;
13+
14+
use crate::config::OutputOptions;
15+
16+
/// Apply the configured operation and schema filters to `doc` in place.
17+
pub fn apply(doc: &mut OpenAPI, opts: &OutputOptions) {
18+
filter_operations(doc, opts);
19+
exclude_schemas(doc, &opts.exclude_schemas);
20+
}
21+
22+
/// Whether any operation-level (tag or operation-id) filter is configured.
23+
fn has_operation_filters(opts: &OutputOptions) -> bool {
24+
return !opts.include_tags.is_empty()
25+
|| !opts.exclude_tags.is_empty()
26+
|| !opts.include_operation_ids.is_empty()
27+
|| !opts.exclude_operation_ids.is_empty();
28+
}
29+
30+
/// Remove operations that fail any active tag or operation-id filter.
31+
fn filter_operations(doc: &mut OpenAPI, opts: &OutputOptions) {
32+
if !has_operation_filters(opts) {
33+
return;
34+
}
35+
for (_, entry) in doc.paths.paths.iter_mut() {
36+
let ReferenceOr::Item(item) = entry else {
37+
continue;
38+
};
39+
for slot in operation_slots(item) {
40+
let remove = slot.as_ref().is_some_and(|op| {
41+
return is_filtered_out(op, opts);
42+
});
43+
if remove {
44+
*slot = None;
45+
}
46+
}
47+
}
48+
}
49+
50+
/// Mutable references to every operation slot on a path item, in a stable
51+
/// verb order (`get`, `put`, `post`, `delete`, `options`, `head`, `patch`,
52+
/// `trace`).
53+
fn operation_slots(item: &mut PathItem) -> [&mut Option<Operation>; 8] {
54+
return [
55+
&mut item.get,
56+
&mut item.put,
57+
&mut item.post,
58+
&mut item.delete,
59+
&mut item.options,
60+
&mut item.head,
61+
&mut item.patch,
62+
&mut item.trace,
63+
];
64+
}
65+
66+
/// Whether `operation` should be dropped given the configured filters.
67+
///
68+
/// An operation is kept only when it carries none of the excluded tags, carries
69+
/// one of the included tags (when `include-tags` is set), is not an excluded
70+
/// operation-id, and is an included operation-id (when `include-operation-ids`
71+
/// is set) — matching `oapi-codegen`'s sequential exclude-then-include filters.
72+
fn is_filtered_out(operation: &Operation, opts: &OutputOptions) -> bool {
73+
if !opts.exclude_tags.is_empty()
74+
&& operation.tags.iter().any(|tag| {
75+
return opts.exclude_tags.contains(tag);
76+
})
77+
{
78+
return true;
79+
}
80+
if !opts.include_tags.is_empty()
81+
&& !operation.tags.iter().any(|tag| {
82+
return opts.include_tags.contains(tag);
83+
})
84+
{
85+
return true;
86+
}
87+
let id = operation.operation_id.as_deref();
88+
if !opts.exclude_operation_ids.is_empty()
89+
&& id.is_some_and(|id| return contains_str(&opts.exclude_operation_ids, id))
90+
{
91+
return true;
92+
}
93+
if !opts.include_operation_ids.is_empty()
94+
&& !id.is_some_and(|id| return contains_str(&opts.include_operation_ids, id))
95+
{
96+
return true;
97+
}
98+
return false;
99+
}
100+
101+
/// Whether `values` contains `needle`.
102+
fn contains_str(values: &[String], needle: &str) -> bool {
103+
return values.iter().any(|value| {
104+
return value == needle;
105+
});
106+
}
107+
108+
/// Drop component schemas whose names appear in `exclude`.
109+
fn exclude_schemas(doc: &mut OpenAPI, exclude: &[String]) {
110+
if exclude.is_empty() {
111+
return;
112+
}
113+
if let Some(components) = doc.components.as_mut() {
114+
components.schemas.retain(|name, _| {
115+
return !exclude.contains(name);
116+
});
117+
}
118+
}
119+
120+
#[cfg(test)]
121+
mod tests {
122+
use super::*;
123+
124+
/// `operation_slots` must reference every operation verb openapiv3
125+
/// recognises: nulling all slots must leave the path item with no
126+
/// operations. This cross-checks our hand-listed slots against openapiv3's
127+
/// authoritative `PathItem::iter`, catching drift if a verb is ever added.
128+
#[test]
129+
fn operation_slots_cover_every_verb() {
130+
let populated = || {
131+
return Some(Operation::default());
132+
};
133+
let mut item = PathItem {
134+
get: populated(),
135+
put: populated(),
136+
post: populated(),
137+
delete: populated(),
138+
options: populated(),
139+
head: populated(),
140+
patch: populated(),
141+
trace: populated(),
142+
..Default::default()
143+
};
144+
assert_eq!(
145+
item.iter().count(),
146+
operation_slots(&mut item).len(),
147+
"every populated verb should map to one slot"
148+
);
149+
for slot in operation_slots(&mut item) {
150+
*slot = None;
151+
}
152+
assert_eq!(
153+
item.iter().count(),
154+
0,
155+
"operation_slots must cover every verb reported by openapiv3's PathItem::iter",
156+
);
157+
}
158+
}

crates/oapi-codegen/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
pub mod config;
99
pub mod emit;
1010
pub mod error;
11+
pub mod filter;
1112
pub mod ir;
1213
pub mod loader;
1314
pub mod lower;
@@ -28,7 +29,8 @@ use crate::loader::Spec;
2829
/// server interface is appended when `generate.std-http-server` is set; the
2930
/// blocking `reqwest` client is appended when `generate.client` is set.
3031
pub fn generate(spec_path: &Path, config: &Config) -> Result<String> {
31-
let spec = Spec::load(spec_path)?;
32+
let mut spec = Spec::load(spec_path)?;
33+
spec.apply_filters(&config.output_options);
3234
let want_server = config.generate.std_http_server;
3335
let want_client = config.generate.client;
3436
let mut module = if config.generate.models || want_server || want_client {

crates/oapi-codegen/src/loader.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ impl Spec {
109109
return &self.source;
110110
}
111111

112+
/// Apply the configured operation and schema filters, mutating the spec in
113+
/// place before lowering (see [`crate::filter`]).
114+
pub fn apply_filters(&mut self, opts: &crate::config::OutputOptions) {
115+
crate::filter::apply(&mut self.inner, opts);
116+
}
117+
112118
/// The component schemas declared in the document, in document order.
113119
pub fn schemas(&self) -> &IndexMap<String, ReferenceOr<Schema>> {
114120
let empty = EMPTY_SCHEMAS.get_or_init(IndexMap::new);

crates/oapi-codegen/tests/coverage.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ const SERVER_FIXTURES: &[&str] = &[
380380
"server_multi_content_request",
381381
"server_multi_content_response",
382382
"server_prune",
383+
"server_filtering",
383384
];
384385

385386
/// Server fixtures whose generation must fail with a documented error, covering
@@ -615,6 +616,7 @@ server_generated_tests!(
615616
server_multi_content_request,
616617
server_multi_content_response,
617618
server_prune,
619+
server_filtering,
618620
);
619621

620622
/// The server `#[test]`s must cover exactly the supported server fixtures.
@@ -668,6 +670,130 @@ fn skip_prune_retains_unused_schemas() {
668670
);
669671
}
670672

673+
/// Path to the shared filtering fixture (three tagged/untagged operations).
674+
fn filtering_fixture() -> PathBuf {
675+
return tests_dir().join("fixtures").join("server_filtering.yaml");
676+
}
677+
678+
/// `exclude-tags` drops operations carrying an excluded tag, and pruning then
679+
/// removes any component schema the dropped operation uniquely referenced.
680+
#[test]
681+
fn filter_exclude_tags_drops_tagged_operations() {
682+
let mut config = server_config();
683+
config.output_options.exclude_tags = vec!["admin".to_owned()];
684+
685+
let generated =
686+
oapi_codegen::generate(&filtering_fixture(), &config).expect("generating filtered server output failed");
687+
assert!(
688+
generated.contains("fn list_pets") && generated.contains("fn health_check"),
689+
"operations without the excluded tag must be kept",
690+
);
691+
assert!(
692+
!generated.contains("fn get_stats"),
693+
"operations carrying an excluded tag must be dropped",
694+
);
695+
assert!(
696+
generated.contains("struct Pet") && !generated.contains("struct Stats"),
697+
"a schema referenced only by a dropped operation must be pruned",
698+
);
699+
}
700+
701+
/// `include-tags` keeps only operations carrying one of the included tags;
702+
/// untagged operations are dropped.
703+
#[test]
704+
fn filter_include_tags_keeps_only_tagged_operations() {
705+
let mut config = server_config();
706+
config.output_options.include_tags = vec!["pets".to_owned()];
707+
708+
let generated =
709+
oapi_codegen::generate(&filtering_fixture(), &config).expect("generating filtered server output failed");
710+
assert!(
711+
generated.contains("fn list_pets"),
712+
"operations with an included tag must be kept",
713+
);
714+
assert!(
715+
!generated.contains("fn get_stats") && !generated.contains("fn health_check"),
716+
"operations without an included tag (including untagged) must be dropped",
717+
);
718+
}
719+
720+
/// `exclude-operation-ids` drops operations by `operationId`; the rest survive.
721+
#[test]
722+
fn filter_exclude_operation_ids_drops_named_operations() {
723+
let mut config = server_config();
724+
config.output_options.exclude_operation_ids = vec!["healthCheck".to_owned()];
725+
726+
let generated =
727+
oapi_codegen::generate(&filtering_fixture(), &config).expect("generating filtered server output failed");
728+
assert!(
729+
generated.contains("fn list_pets") && generated.contains("fn get_stats"),
730+
"operations not named in exclude-operation-ids must be kept",
731+
);
732+
assert!(
733+
!generated.contains("fn health_check"),
734+
"operations named in exclude-operation-ids must be dropped",
735+
);
736+
}
737+
738+
/// `include-operation-ids` keeps only operations whose `operationId` is listed.
739+
#[test]
740+
fn filter_include_operation_ids_keeps_only_named_operations() {
741+
let mut config = server_config();
742+
config.output_options.include_operation_ids = vec!["listPets".to_owned()];
743+
744+
let generated =
745+
oapi_codegen::generate(&filtering_fixture(), &config).expect("generating filtered server output failed");
746+
assert!(
747+
generated.contains("fn list_pets"),
748+
"operations named in include-operation-ids must be kept",
749+
);
750+
assert!(
751+
!generated.contains("fn get_stats") && !generated.contains("fn health_check"),
752+
"operations not named in include-operation-ids must be dropped",
753+
);
754+
}
755+
756+
/// `exclude-schemas` removes named component schemas from models generation,
757+
/// leaving the rest intact. Models-only generation never prunes, so a negative
758+
/// control (generation without the option) proves the removal is attributable to
759+
/// `exclude-schemas` and not to pruning.
760+
#[test]
761+
fn filter_exclude_schemas_removes_named_models() {
762+
let models_only = oapi_codegen::config::Generate {
763+
models: true,
764+
..Default::default()
765+
};
766+
767+
let baseline = oapi_codegen::Config {
768+
generate: models_only.clone(),
769+
..Default::default()
770+
};
771+
let unfiltered =
772+
oapi_codegen::generate(&filtering_fixture(), &baseline).expect("generating baseline models failed");
773+
assert!(
774+
unfiltered.contains("struct Standalone"),
775+
"without exclude-schemas the schema must be generated (models-only never prunes)",
776+
);
777+
778+
let config = oapi_codegen::Config {
779+
generate: models_only,
780+
output_options: oapi_codegen::config::OutputOptions {
781+
exclude_schemas: vec!["Standalone".to_owned()],
782+
..Default::default()
783+
},
784+
..Default::default()
785+
};
786+
let generated = oapi_codegen::generate(&filtering_fixture(), &config).expect("generating filtered models failed");
787+
assert!(
788+
generated.contains("struct Pet") && generated.contains("struct Stats"),
789+
"schemas not named in exclude-schemas must be generated",
790+
);
791+
assert!(
792+
!generated.contains("struct Standalone"),
793+
"schemas named in exclude-schemas must not be generated",
794+
);
795+
}
796+
671797
/// Configuration that enables the blocking `reqwest` client generator.
672798
fn client_config() -> oapi_codegen::Config {
673799
return oapi_codegen::Config {

0 commit comments

Comments
 (0)