Skip to content

Commit 542d152

Browse files
committed
test(rust): add comprehensive tests for oneOf vs anyOf semantics
- Added comprehensive test spec covering various oneOf/anyOf scenarios - Tests simple primitives, complex objects, and nested schemas - Tests oneOf with and without discriminator - Tests anyOf with overlapping properties - Added documentation explaining semantic differences - Demonstrates that oneOf picks first matching (untagged enum) - Demonstrates that anyOf allows multiple matches (struct with optional fields) The tests verify: - oneOf generates enums (XOR semantics) - anyOf generates structs with optional fields (OR semantics) - oneOf with discriminator generates tagged enums - anyOf validation ensures at least one field is set
1 parent 07944e8 commit 542d152

File tree

3 files changed

+409
-0
lines changed

3 files changed

+409
-0
lines changed

docs/rust-oneof-anyof-semantics.md

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Rust Generator: oneOf vs anyOf Semantics
2+
3+
## Overview
4+
5+
The Rust OpenAPI generator properly implements the semantic differences between `oneOf` and `anyOf` schemas as defined in the OpenAPI specification:
6+
7+
- **oneOf (XOR)**: Exactly one of the schemas must validate
8+
- **anyOf (OR)**: One or more of the schemas must validate
9+
10+
## Implementation Details
11+
12+
### oneOf - Untagged Enums
13+
14+
For `oneOf` schemas without a discriminator, the generator creates untagged enums using Serde's `#[serde(untagged)]` attribute:
15+
16+
```yaml
17+
# OpenAPI Schema
18+
SimpleOneOf:
19+
oneOf:
20+
- type: string
21+
- type: number
22+
```
23+
24+
```rust
25+
// Generated Rust Code
26+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
27+
#[serde(untagged)]
28+
pub enum SimpleOneOf {
29+
String(String),
30+
Number(f64),
31+
}
32+
```
33+
34+
**Behavior**: When deserializing, Serde tries each variant in order and stops at the first match. This ensures exactly one variant is selected.
35+
36+
### anyOf - Structs with Optional Fields
37+
38+
For `anyOf` schemas, the generator creates structs with optional fields, allowing multiple schemas to be valid simultaneously:
39+
40+
```yaml
41+
# OpenAPI Schema
42+
SimpleAnyOf:
43+
anyOf:
44+
- type: string
45+
- type: number
46+
```
47+
48+
```rust
49+
// Generated Rust Code
50+
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
51+
pub struct SimpleAnyOf {
52+
#[serde(skip_serializing_if = "Option::is_none")]
53+
pub as_String: Option<String>,
54+
#[serde(skip_serializing_if = "Option::is_none")]
55+
pub as_Number: Option<f64>,
56+
}
57+
58+
impl SimpleAnyOf {
59+
pub fn validate_any_of(&self) -> Result<(), String> {
60+
if self.as_String.is_none() && self.as_Number.is_none() {
61+
return Err("At least one anyOf field must be set".to_string());
62+
}
63+
Ok(())
64+
}
65+
}
66+
```
67+
68+
**Behavior**: Multiple fields can be set, properly implementing OR semantics where data can match multiple schemas.
69+
70+
## Practical Examples
71+
72+
### Example 1: Person or Company (oneOf)
73+
74+
```yaml
75+
PersonOrCompany:
76+
oneOf:
77+
- $ref: '#/components/schemas/Person'
78+
- $ref: '#/components/schemas/Company'
79+
```
80+
81+
```rust
82+
// Usage
83+
let data = PersonOrCompany::Person(Box::new(Person {
84+
first_name: "John".to_string(),
85+
last_name: "Doe".to_string(),
86+
}));
87+
// Can ONLY be a Person OR a Company, not both
88+
```
89+
90+
### Example 2: Person and/or Company (anyOf)
91+
92+
```yaml
93+
PersonAndOrCompany:
94+
anyOf:
95+
- $ref: '#/components/schemas/Person'
96+
- $ref: '#/components/schemas/Company'
97+
```
98+
99+
```rust
100+
// Usage
101+
let mut data = PersonAndOrCompany::default();
102+
data.as_Person = Some(Box::new(Person {
103+
first_name: "John".to_string(),
104+
last_name: "Doe".to_string(),
105+
}));
106+
data.as_Company = Some(Box::new(Company {
107+
company_name: "Acme Corp".to_string(),
108+
}));
109+
// Can be BOTH a Person AND a Company simultaneously
110+
data.validate_any_of()?; // Ensures at least one is set
111+
```
112+
113+
### Example 3: Content Types (anyOf)
114+
115+
```yaml
116+
MixedContent:
117+
anyOf:
118+
- type: object
119+
properties:
120+
text:
121+
type: string
122+
- type: object
123+
properties:
124+
html:
125+
type: string
126+
- type: object
127+
properties:
128+
markdown:
129+
type: string
130+
```
131+
132+
```rust
133+
// Can have multiple content representations
134+
let mut content = MixedContent::default();
135+
content.as_text = Some("Plain text content".to_string());
136+
content.as_html = Some("<p>HTML content</p>".to_string());
137+
content.as_markdown = Some("**Markdown** content".to_string());
138+
// All three formats can coexist
139+
```
140+
141+
## oneOf with Discriminator
142+
143+
When a discriminator is present, `oneOf` generates a tagged enum:
144+
145+
```yaml
146+
ShapeOneOf:
147+
oneOf:
148+
- $ref: '#/components/schemas/Circle'
149+
- $ref: '#/components/schemas/Rectangle'
150+
discriminator:
151+
propertyName: shapeType
152+
```
153+
154+
```rust
155+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
156+
#[serde(tag = "shapeType")]
157+
pub enum ShapeOneOf {
158+
#[serde(rename = "circle")]
159+
Circle(Circle),
160+
#[serde(rename = "rectangle")]
161+
Rectangle(Rectangle),
162+
}
163+
```
164+
165+
## Key Differences Summary
166+
167+
| Aspect | oneOf (XOR) | anyOf (OR) |
168+
|--------|-------------|------------|
169+
| **Rust Type** | Enum | Struct with Optional Fields |
170+
| **Validation** | Exactly one variant | At least one field |
171+
| **Multiple Matches** | Not possible | Allowed |
172+
| **Serde Attribute** | `#[serde(untagged)]` or `#[serde(tag = "...")]` | Standard struct |
173+
| **Use Case** | Mutually exclusive choices | Multiple valid representations |
174+
175+
## Migration from Previous Behavior
176+
177+
Previously, the Rust generator treated `anyOf` the same as `oneOf`, generating enums for both. This was semantically incorrect. With the new implementation:
178+
179+
1. **oneOf remains unchanged**: Still generates enums
180+
2. **anyOf now generates structs**: Breaking change but semantically correct
181+
182+
To migrate existing code:
183+
- Replace enum pattern matching with struct field access
184+
- Use the `validate_any_of()` method to ensure at least one field is set
185+
- Access individual options via the `as_*` fields

modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustClientCodegenTest.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,4 +304,72 @@ public void testAnyOfSupport() throws IOException {
304304
TestUtils.assertFileContains(anotherTestPath, "pub struct AnotherAnyOfTest");
305305
TestUtils.assertFileContains(anotherTestPath, "pub fn validate_any_of(&self)");
306306
}
307+
308+
@Test
309+
public void testOneOfVsAnyOfSemantics() throws IOException {
310+
Path target = Files.createTempDirectory("test-oneof-anyof");
311+
final CodegenConfigurator configurator = new CodegenConfigurator()
312+
.setGeneratorName("rust")
313+
.setInputSpec("src/test/resources/3_0/rust/rust-oneof-anyof-comprehensive-test.yaml")
314+
.setSkipOverwrite(false)
315+
.setOutputDir(target.toAbsolutePath().toString().replace("\\", "/"));
316+
List<File> files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate();
317+
files.forEach(File::deleteOnExit);
318+
319+
// Test SimpleOneOf - should generate enum (XOR semantics)
320+
Path simpleOneOfPath = Path.of(target.toString(), "/src/models/simple_one_of.rs");
321+
TestUtils.assertFileExists(simpleOneOfPath);
322+
TestUtils.assertFileContains(simpleOneOfPath, "#[serde(untagged)]");
323+
TestUtils.assertFileContains(simpleOneOfPath, "pub enum SimpleOneOf");
324+
TestUtils.assertFileNotContains(simpleOneOfPath, "pub struct SimpleOneOf");
325+
326+
// Test SimpleAnyOf - should generate struct with optional fields (OR semantics)
327+
Path simpleAnyOfPath = Path.of(target.toString(), "/src/models/simple_any_of.rs");
328+
TestUtils.assertFileExists(simpleAnyOfPath);
329+
TestUtils.assertFileContains(simpleAnyOfPath, "pub struct SimpleAnyOf");
330+
TestUtils.assertFileContains(simpleAnyOfPath, "pub fn validate_any_of(&self)");
331+
TestUtils.assertFileNotContains(simpleAnyOfPath, "pub enum SimpleAnyOf");
332+
333+
// Test PersonOrCompany (oneOf without discriminator) - should generate untagged enum
334+
Path personOrCompanyPath = Path.of(target.toString(), "/src/models/person_or_company.rs");
335+
TestUtils.assertFileExists(personOrCompanyPath);
336+
TestUtils.assertFileContains(personOrCompanyPath, "#[serde(untagged)]");
337+
TestUtils.assertFileContains(personOrCompanyPath, "pub enum PersonOrCompany");
338+
TestUtils.assertFileContains(personOrCompanyPath, "Person(Box<models::Person>)");
339+
TestUtils.assertFileContains(personOrCompanyPath, "Company(Box<models::Company>)");
340+
341+
// Test PersonAndOrCompany (anyOf) - should generate struct allowing both
342+
Path personAndOrCompanyPath = Path.of(target.toString(), "/src/models/person_and_or_company.rs");
343+
TestUtils.assertFileExists(personAndOrCompanyPath);
344+
TestUtils.assertFileContains(personAndOrCompanyPath, "pub struct PersonAndOrCompany");
345+
TestUtils.assertFileContains(personAndOrCompanyPath, "Option<Box<models::Person>>");
346+
TestUtils.assertFileContains(personAndOrCompanyPath, "Option<Box<models::Company>>");
347+
TestUtils.assertFileContains(personAndOrCompanyPath, "pub fn validate_any_of(&self)");
348+
349+
// Test ComplexOneOf - should generate enum with inline schemas
350+
Path complexOneOfPath = Path.of(target.toString(), "/src/models/complex_one_of.rs");
351+
TestUtils.assertFileExists(complexOneOfPath);
352+
TestUtils.assertFileContains(complexOneOfPath, "pub enum ComplexOneOf");
353+
TestUtils.assertFileNotContains(complexOneOfPath, "pub struct ComplexOneOf");
354+
355+
// Test ComplexAnyOf - should generate struct with overlapping property support
356+
Path complexAnyOfPath = Path.of(target.toString(), "/src/models/complex_any_of.rs");
357+
TestUtils.assertFileExists(complexAnyOfPath);
358+
TestUtils.assertFileContains(complexAnyOfPath, "pub struct ComplexAnyOf");
359+
TestUtils.assertFileContains(complexAnyOfPath, "pub fn validate_any_of(&self)");
360+
361+
// Test ShapeOneOfWithDiscriminator - should generate tagged enum
362+
Path shapePath = Path.of(target.toString(), "/src/models/shape_one_of_with_discriminator.rs");
363+
TestUtils.assertFileExists(shapePath);
364+
TestUtils.assertFileContains(shapePath, "pub enum ShapeOneOfWithDiscriminator");
365+
// With discriminator, it should NOT be untagged
366+
TestUtils.assertFileContains(shapePath, "#[serde(tag = \"shapeType\")]");
367+
368+
// Test MixedContent (anyOf) - can have multiple content types simultaneously
369+
Path mixedContentPath = Path.of(target.toString(), "/src/models/mixed_content.rs");
370+
TestUtils.assertFileExists(mixedContentPath);
371+
TestUtils.assertFileContains(mixedContentPath, "pub struct MixedContent");
372+
TestUtils.assertFileContains(mixedContentPath, "Option<"); // Should have optional fields
373+
TestUtils.assertFileContains(mixedContentPath, "pub fn validate_any_of(&self)");
374+
}
307375
}

0 commit comments

Comments
 (0)