Skip to content

Commit 67ecdb9

Browse files
authored
fix: more fixes to duplicates and collisions (#80)
1 parent 03d2f64 commit 67ecdb9

12 files changed

Lines changed: 418 additions & 18 deletions

crates/oapi-codegen/src/console.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ fn hints_for(err: &Error) -> Vec<String> {
235235
}
236236
Error::TypeNameCollision { hint, .. }
237237
| Error::SchemaNameCollision { hint, .. }
238+
| Error::OperationNameCollision { hint, .. }
238239
| Error::InvalidTypeNameSuffix { hint, .. } => {
239240
return vec![hint.clone()];
240241
}

crates/oapi-codegen/src/error.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,26 @@ pub enum Error {
155155
hint: String,
156156
},
157157

158+
/// Two operations collapsed onto one Rust method name.
159+
///
160+
/// Every artifact of an operation derives from this one name, so the file
161+
/// holds a duplicate trait method, response enum, and handler, and the router
162+
/// points both routes at one handler. The generator will not choose which
163+
/// operation keeps the plain name, because that choice belongs to the spec
164+
/// author.
165+
OperationNameCollision {
166+
/// The Rust method name that both operations produce.
167+
ident: String,
168+
/// `method path` of the operation that claimed the name first, in
169+
/// document order.
170+
first: String,
171+
/// `method path` of the operation that collided with `first`.
172+
second: String,
173+
/// How to resolve the clash. Rendered by the console as a hint, and not
174+
/// by `Display`, so the console does not print it twice.
175+
hint: String,
176+
},
177+
158178
/// `output-options.type-name-suffix` holds no identifier characters.
159179
///
160180
/// Casing drops punctuation and separators, so a suffix such as `-` or `_`
@@ -256,6 +276,14 @@ impl std::fmt::Display for Error {
256276
"component schemas `{first}` and `{second}` both produce the Rust type name `{ident}`"
257277
);
258278
}
279+
Error::OperationNameCollision {
280+
ident, first, second, ..
281+
} => {
282+
return write!(
283+
f,
284+
"operations `{first}` and `{second}` both produce the Rust method name `{ident}`"
285+
);
286+
}
259287
Error::InvalidTypeNameSuffix { suffix, .. } => {
260288
return write!(
261289
f,
@@ -297,6 +325,7 @@ impl std::error::Error for Error {
297325
| Error::SchemaDepthExceeded { .. }
298326
| Error::TypeNameCollision { .. }
299327
| Error::SchemaNameCollision { .. }
328+
| Error::OperationNameCollision { .. }
300329
| Error::InvalidTypeNameSuffix { .. }
301330
| Error::InvalidPathParameter { .. }
302331
| Error::UndeclaredPathParameter { .. }

crates/oapi-codegen/src/lower/paths.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ use crate::lower::schema::string_format_type;
8585
use crate::lower::security;
8686
use crate::naming::Case;
8787
use crate::naming::RustIdent;
88+
use crate::naming::X_RUST_NAME;
8889
use crate::naming::operations;
8990
use crate::naming::to_ident;
9091

@@ -169,6 +170,13 @@ impl Lowerer<'_> {
169170
let catalogue = security::scheme_catalogue(self.spec);
170171
let mut operations = Vec::new();
171172
let mut used_schemes: Vec<String> = Vec::new();
173+
// Maps a claimed method name back to the `method path` that claimed it, so
174+
// a collision error can name the earlier operation and not the identifier
175+
// alone. Filtering runs before lowering (see `crate::filter`), and nothing
176+
// prunes an operation afterwards, so every name claimed here reaches the
177+
// file and every collision found here is real.
178+
let mut claimed: BTreeMap<String, String> = BTreeMap::new();
179+
let mut collisions = crate::lower::validate::Diagnostics::new();
172180
for (path, entry) in self.spec.paths().iter() {
173181
let item = match entry {
174182
ReferenceOr::Item(item) => item,
@@ -182,6 +190,24 @@ impl Lowerer<'_> {
182190
};
183191
for (method, operation) in item.iter() {
184192
let mut lowered = self.lower_operation(path, method, operation, &item.parameters)?;
193+
let route = format!("{method} {path}");
194+
match claimed.get(lowered.name.logical()) {
195+
Some(first) => {
196+
collisions.push(Error::OperationNameCollision {
197+
ident: lowered.name.logical().to_owned(),
198+
first: first.clone(),
199+
second: route,
200+
hint: operation_collision_hint(operation),
201+
});
202+
// Do not lower this operation into the service. Its
203+
// artifacts all derive from the colliding name, so keeping
204+
// it would emit the duplicates this check exists to stop.
205+
continue;
206+
}
207+
None => {
208+
claimed.insert(lowered.name.logical().to_owned(), route);
209+
}
210+
}
185211
lowered.security = self.operation_security(operation);
186212
for key in &lowered.security {
187213
if !used_schemes.iter().any(|existing| return existing == key) {
@@ -191,6 +217,7 @@ impl Lowerer<'_> {
191217
operations.push(lowered);
192218
}
193219
}
220+
collisions.into_result()?;
194221
let security_schemes = catalogue
195222
.into_iter()
196223
.filter(|scheme| return used_schemes.iter().any(|key| return *key == scheme.key))
@@ -1444,16 +1471,58 @@ impl Lowerer<'_> {
14441471
}
14451472
}
14461473

1447-
/// Derive the trait method name: the `operationId` if present, else a name
1448-
/// synthesised from the method and path (for example `get /v1/widgets` -> `get_v1_widgets`).
1474+
/// Derive the trait method name: an explicit `x-rust-name`, else the
1475+
/// `operationId`, else a name synthesised from the method and path (for example
1476+
/// `get /v1/widgets` -> `get_v1_widgets`).
1477+
///
1478+
/// `x-rust-name` is the escape hatch for two `operationId`s that collapse onto one
1479+
/// Rust name. It takes precedence over `operationId`, the same way it does for a
1480+
/// schema, so the author names the method and the generator does not.
14491481
fn operation_name(path: &str, method: &str, operation: &OasOperation) -> crate::naming::RustIdent {
1482+
if let Some(name) = operation
1483+
.extensions
1484+
.get(X_RUST_NAME)
1485+
.and_then(|value| return value.as_str())
1486+
{
1487+
return operations::operation_method_name(name);
1488+
}
14501489
if let Some(id) = &operation.operation_id {
14511490
return operations::operation_method_name(id);
14521491
}
14531492
let synthesised = format!("{method} {path}");
14541493
return operations::operation_method_name(&synthesised);
14551494
}
14561495

1496+
/// Build the remedy text for an operation-name collision.
1497+
///
1498+
/// `operation` is the one that collided, and the advice depends on what it already
1499+
/// declares. An operation that sets `x-rust-name` needs a different name and not
1500+
/// the extension it already uses. An operation with no `operationId` derives its
1501+
/// name from the method and path, so adding an `operationId` is the natural fix.
1502+
///
1503+
/// There is no suffix option for operations, unlike `type-name-suffix` for
1504+
/// schemas. A method name appears in the trait a consumer implements, so every
1505+
/// name a consumer writes stays the author's choice.
1506+
fn operation_collision_hint(operation: &OasOperation) -> String {
1507+
if operation.extensions.contains_key(X_RUST_NAME) {
1508+
return format!(
1509+
"This operation already sets `{X_RUST_NAME}`, and that name collides too. \
1510+
Give it a name that no other operation uses."
1511+
);
1512+
}
1513+
if operation.operation_id.is_some() {
1514+
return format!(
1515+
"Two `operationId`s that differ only in case or in punctuation produce one Rust name. \
1516+
Give one of the two operations a different `operationId`, or set `{X_RUST_NAME}` on it \
1517+
to name the generated method directly."
1518+
);
1519+
}
1520+
return format!(
1521+
"This operation declares no `operationId`, so its name comes from the method and the path. \
1522+
Add an `operationId`, or set `{X_RUST_NAME}` on it to name the generated method directly."
1523+
);
1524+
}
1525+
14571526
/// The operation's doc comment, preferring `summary` over `description`.
14581527
fn operation_doc(operation: &OasOperation) -> Option<String> {
14591528
if let Some(summary) = &operation.summary

crates/oapi-codegen/src/naming.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
use proc_macro2::Ident;
55
use proc_macro2::Span;
66

7-
/// The `x-rust-name` extension key: override a generated identifier (of a type,
8-
/// field, or server) with a caller-supplied name. Shared so every consumer and
9-
/// any user-facing message names the same key.
7+
/// The `x-rust-name` extension key: override a generated identifier with a
8+
/// caller-supplied name. It applies to a top-level schema (the type name), a
9+
/// property (the field name), an operation (the method name, and so the name of
10+
/// every artifact derived from it), and a `servers:` entry (the server URL name).
11+
/// Shared so every consumer and any user-facing message names the same key.
1012
pub(crate) const X_RUST_NAME: &str = "x-rust-name";
1113

1214
/// `snake_case` / `UpperCamelCase` conversion used by [`to_ident`].

crates/oapi-codegen/tests/coverage.rs

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -459,19 +459,27 @@ const COMBINED_UNSUPPORTED_FIXTURES: &[&str] = &[
459459
"combined_reserved_name_client_error",
460460
];
461461

462-
/// Model-only fixtures for schema-name collisions. The golden-file tests do not
463-
/// cover these, because one must fail and the other needs a config option.
462+
/// Fixtures for name collisions. The golden-file tests do not cover these,
463+
/// because some must fail and others need a config option or an extension.
464464
///
465465
/// `type_name_collision_error` must fail. Two schema names collapse onto one Rust
466466
/// identifier, and the spec offers no remedy. `type_name_collision_suffix` must
467-
/// succeed, because `output-options.type-name-suffix` resolves the collision. The
468-
/// tests below exercise both.
467+
/// succeed, because `output-options.type-name-suffix` resolves the collision.
468+
///
469+
/// The `operation_name_collision_*` fixtures do the same for a method name.
470+
/// `_error` and `_synthesised` must fail, `_rust_name` succeeds through
471+
/// `x-rust-name`, and `_filtered` succeeds because a filter removes one of the two
472+
/// operations before lowering. The tests below exercise each one.
469473
const NAMING_COLLISION_FIXTURES: &[&str] = &[
470474
"type_name_collision_error",
471475
"type_name_collision_suffix",
472476
"type_name_collision_pruned",
473477
"type_name_collision_reachable",
474478
"type_name_collision_inline_overlap",
479+
"operation_name_collision_error",
480+
"operation_name_collision_rust_name",
481+
"operation_name_collision_synthesised",
482+
"operation_name_collision_filtered",
475483
];
476484

477485
/// Absolute path to the crate's `tests` directory.
@@ -1377,6 +1385,120 @@ fn collision_between_unused_schemas_fails_for_models_only() {
13771385
assert_eq!(problems.len(), 2, "expected both collisions, got: {problems:?}");
13781386
}
13791387

1388+
/// Two `operationId`s that collapse onto one Rust method name must stop
1389+
/// generation, and one run must report every collision.
1390+
///
1391+
/// Every artifact of an operation derives from its method name, so a collision
1392+
/// emitted a duplicate trait method, response enum, and handler, and pointed both
1393+
/// routes at one handler. `rustc` rejects that with `E0428`.
1394+
#[test]
1395+
fn colliding_operation_ids_fail() {
1396+
let fixture = tests_dir().join("fixtures").join("operation_name_collision_error.yaml");
1397+
let err = oapi_codegen::generate(&fixture, &server_config())
1398+
.expect_err("two operations that produce one method name must stop generation");
1399+
let oapi_codegen::Error::Validation { problems } = &err else {
1400+
panic!("expected an aggregated Validation error, got: {err:?}");
1401+
};
1402+
assert_eq!(problems.len(), 2, "expected both collisions, got: {problems:?}");
1403+
let report = problems
1404+
.iter()
1405+
.map(|problem| {
1406+
return problem.to_string();
1407+
})
1408+
.collect::<Vec<String>>()
1409+
.join("\n");
1410+
assert!(
1411+
report.contains("list_widgets") && report.contains("get_thing"),
1412+
"the report names both colliding method names, got: {report}",
1413+
);
1414+
assert!(
1415+
report.contains("get /widgets") && report.contains("get /gadgets"),
1416+
"the report names the route of each colliding operation, got: {report}",
1417+
);
1418+
}
1419+
1420+
/// The client emitter derives its names from the same lowered operation, so it
1421+
/// must reject the same collision. One check in the shared lowering pass covers
1422+
/// both emitters.
1423+
#[test]
1424+
fn colliding_operation_ids_fail_for_the_client() {
1425+
let fixture = tests_dir().join("fixtures").join("operation_name_collision_error.yaml");
1426+
let err = oapi_codegen::generate(&fixture, &client_config())
1427+
.expect_err("a client run derives the same names, so the collision must stop it");
1428+
let oapi_codegen::Error::Validation { problems } = &err else {
1429+
panic!("expected an aggregated Validation error, got: {err:?}");
1430+
};
1431+
assert_eq!(problems.len(), 2, "expected both collisions, got: {problems:?}");
1432+
}
1433+
1434+
/// `x-rust-name` is the only escape hatch for an operation, and it names every
1435+
/// artifact of that operation and not the trait method alone.
1436+
#[test]
1437+
fn x_rust_name_resolves_an_operation_collision() {
1438+
let fixture = tests_dir()
1439+
.join("fixtures")
1440+
.join("operation_name_collision_rust_name.yaml");
1441+
let code = oapi_codegen::generate(&fixture, &server_config())
1442+
.expect("`x-rust-name` must resolve the collision it exists for");
1443+
for expected in [
1444+
"fn list_widgets",
1445+
"fn list_gadgets",
1446+
"enum ListWidgetsResponse",
1447+
"enum ListGadgetsResponse",
1448+
"list_gadgets_handler",
1449+
] {
1450+
assert!(
1451+
code.contains(expected),
1452+
"the override names every artifact, and `{expected}` is missing from: {code}",
1453+
);
1454+
}
1455+
}
1456+
1457+
/// An operation with no `operationId` takes its name from the method and the path,
1458+
/// so two such paths can collide as well. The remedy differs, because there is no
1459+
/// `operationId` to change.
1460+
#[test]
1461+
fn colliding_synthesised_operation_names_fail() {
1462+
let fixture = tests_dir()
1463+
.join("fixtures")
1464+
.join("operation_name_collision_synthesised.yaml");
1465+
let err = oapi_codegen::generate(&fixture, &server_config())
1466+
.expect_err("two synthesised names that collide must stop generation");
1467+
let oapi_codegen::Error::OperationNameCollision { ident, hint, .. } = &err else {
1468+
panic!("expected a single OperationNameCollision, got: {err:?}");
1469+
};
1470+
assert_eq!(ident, "get_widgets_list");
1471+
assert!(
1472+
hint.contains("declares no `operationId`"),
1473+
"the remedy must address the missing `operationId`, got: {hint}",
1474+
);
1475+
}
1476+
1477+
/// Filtering runs before lowering, so a collision that a filter removes must not
1478+
/// stop generation. This is the trap the schema-name check fell into: reporting a
1479+
/// collision between an item the file holds and one that it never emits.
1480+
#[test]
1481+
fn operation_collision_a_filter_removes_still_generates() {
1482+
let fixture = tests_dir()
1483+
.join("fixtures")
1484+
.join("operation_name_collision_filtered.yaml");
1485+
let mut config = server_config();
1486+
config
1487+
.output_options
1488+
.exclude_operation_ids
1489+
.push("listWidgets".to_owned());
1490+
let code = oapi_codegen::generate(&fixture, &config)
1491+
.expect("only one operation survives the filter, so no collision remains");
1492+
assert!(
1493+
code.contains("fn list_widgets"),
1494+
"the operation that survives is emitted: {code}",
1495+
);
1496+
assert!(
1497+
!code.contains("/gadgets"),
1498+
"the operation the filter removes is not emitted: {code}",
1499+
);
1500+
}
1501+
13801502
/// A parameter declared `in: path` with no matching `{placeholder}` in the path
13811503
/// template must fail generation with a guided error rather than silently drop
13821504
/// the parameter from the generated signature.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Operation name collision without a remedy
4+
version: 1.0.0
5+
paths:
6+
# Two `operationId`s that differ only in punctuation and case collapse onto one
7+
# Rust method name (`list_widgets`). Every artifact of an operation derives from
8+
# that name, so generation must stop.
9+
/widgets:
10+
get:
11+
operationId: list-widgets
12+
responses:
13+
"200":
14+
description: ok
15+
/gadgets:
16+
get:
17+
operationId: listWidgets
18+
responses:
19+
"200":
20+
description: ok
21+
# A second, independent collision on `get_thing`. One run must report this
22+
# collision and the one above together, and not stop at the first one.
23+
/things:
24+
get:
25+
operationId: get-thing
26+
responses:
27+
"200":
28+
description: ok
29+
/stuff:
30+
get:
31+
operationId: getThing
32+
responses:
33+
"200":
34+
description: ok
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
openapi: 3.0.3
2+
info:
3+
title: Operation name collision that a filter removes
4+
version: 1.0.0
5+
paths:
6+
# `list-widgets` and `listWidgets` collide, but `exclude-operation-ids` removes
7+
# the second one before lowering. Only one operation then claims the name, so
8+
# generation must succeed. A check that ran over the unfiltered document would
9+
# report a collision between an operation that the file holds and one that it
10+
# does not.
11+
/widgets:
12+
get:
13+
operationId: list-widgets
14+
responses:
15+
"200":
16+
description: ok
17+
/gadgets:
18+
get:
19+
operationId: listWidgets
20+
responses:
21+
"200":
22+
description: ok

0 commit comments

Comments
 (0)