Skip to content
Merged
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
13 changes: 13 additions & 0 deletions bin/configs/manual/rust-axum-recursive-models.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
generatorName: rust-axum
outputDir: samples/server/petstore/rust-axum/output/rust-axum-recursive-models
inputSpec: modules/openapi-generator/src/test/resources/3_1/rust-axum/rust-axum-recursive-models.yaml
templateDir: modules/openapi-generator/src/main/resources/rust-axum
generateAliasAsModel: true
additionalProperties:
hideGenerationTimestamp: "true"
packageName: rust-axum-recursive-models
boxRecursiveModels: "true"
homePageUrl: https://github.com/openapitools/openapi-generator
globalProperties:
skipFormModel: "false"
enablePostProcessFile: true
1 change: 1 addition & 0 deletions docs/generators/rust-axum.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
| ------ | ----------- | ------ | ------- |
|allowBlockingResponseSerialize|By default, json/form-urlencoded response serialization, which might perform a lot of compute in a future without yielding, is executed on a blocking thread via tokio::task::spawn_blocking. Set this option to true will override this behaviour and allow blocking call to happen. It helps to improve the performance when response serialization (e.g. returns tiny data) is low cost.| |false|
|allowBlockingValidator|By default, validation process, which might perform a lot of compute in a future without yielding, is executed on a blocking thread via tokio::task::spawn_blocking. Set this option to true will override this behaviour and allow blocking call to happen. It helps to improve the performance when validating request-data (header, path, query, body) is low cost.| |false|
|boxRecursiveModels|Wrap model fields that lead back to their own model (directly or through other models) in Box, so recursive models compile. Changes the type of those fields.| |false|
|disableValidator|Disable validating request-data (header, path, query, body) against OpenAPI Schema Specification.| |false|
|packageName|Rust crate name (convention: snake_case).| |openapi|
|packageVersion|Rust crate version.| |null|
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public class RustAxumServerCodegen extends AbstractRustCodegen implements Codege
private Boolean basicAuthorization = false;
private Boolean basicAnalytic = false;
private Boolean ownedRequest = false;
private Boolean boxRecursiveModels = false;

// Types
private static final String uuidType = "uuid::Uuid";
Expand Down Expand Up @@ -251,6 +252,12 @@ public RustAxumServerCodegen() {
optAllowBlockingResponseSerialize.setType("bool");
optAllowBlockingResponseSerialize.defaultValue(allowBlockingResponseSerialize.toString());

CliOption optBoxRecursiveModels = new CliOption("boxRecursiveModels",
String.join("", "Wrap model fields that lead back to their own model (directly or through other ",
"models) in Box, so recursive models compile. Changes the type of those fields."));
optBoxRecursiveModels.setType("bool");
optBoxRecursiveModels.defaultValue(boxRecursiveModels.toString());

cliOptions = new ArrayList<>(
List.of(
new CliOption(CodegenConstants.PACKAGE_NAME,
Expand All @@ -260,7 +267,8 @@ public RustAxumServerCodegen() {
"Rust crate version."),
optDisableValidator,
optAllowBlockingValidator,
optAllowBlockingResponseSerialize
optAllowBlockingResponseSerialize,
optBoxRecursiveModels
)
);

Expand Down Expand Up @@ -359,6 +367,12 @@ public void processOpts() {
} else {
additionalProperties.put("ownedRequest", ownedRequest);
}

if (additionalProperties.containsKey("boxRecursiveModels")) {
boxRecursiveModels = convertPropertyToBooleanAndWriteBack("boxRecursiveModels");
} else {
additionalProperties.put("boxRecursiveModels", boxRecursiveModels);
}
}

private void setPackageName(final String packageName) {
Expand Down Expand Up @@ -1437,6 +1451,70 @@ public ModelsMap postProcessModels(ModelsMap modelsMap) {
return super.postProcessModelsEnum(modelsMap);
}

@Override
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
objs = super.postProcessAllModels(objs);
if (!boxRecursiveModels) {
return objs;
}

// Model fields that lead back to their own model (directly or through other models)
// would make the struct infinitely sized, so they are boxed.
final Map<String, CodegenModel> models = new HashMap<>();
for (ModelsMap modelsMap : objs.values()) {
for (ModelMap mo : modelsMap.getModels()) {
models.put(mo.getModel().classname, mo.getModel());
}
}

for (CodegenModel cm : models.values()) {
for (CodegenProperty var : cm.vars) {
final String target = directModelReference(var);
if (target != null && reachesModel(target, cm.classname, models, new HashSet<>())) {
var.vendorExtensions.put("x-is-boxed", true);
}
}
}

Comment on lines +1460 to +1478

@linxGnu linxGnu Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@teovillanueva
Hi, I don't want to break existing generated code. Please feature-gate it throughout additionalProperties

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 92e3fdc: the boxing is now behind a new boxRecursiveModels option (default false), so without it the generated code is the same as on master. The new rust-axum-recursive-models sample enables it, there is a test for each case, the docs are regenerated, and the integration tests pass.

return objs;
}

/**
* Returns the name of the model a property holds inline (i.e. not inside a container), or null.
*/
private static String directModelReference(CodegenProperty property) {
if (property.isContainer || !property.dataType.startsWith("models::")) {
return null;
}
return property.dataType.substring("models::".length());
}

private static boolean reachesModel(String from, String to, Map<String, CodegenModel> models, Set<String> visited) {
if (from.equals(to)) {
return true;
}
final CodegenModel cm = models.get(from);
if (cm == null || !visited.add(from)) {
return false;
}
// oneOf/anyOf models are enums that hold their variants inline
final List<CodegenProperty> fields = new ArrayList<>(cm.vars);
final CodegenComposedSchemas cs = cm.getComposedSchemas();
if (cs != null && cs.getOneOf() != null) {
fields.addAll(cs.getOneOf());
}
if (cs != null && cs.getAnyOf() != null) {
fields.addAll(cs.getAnyOf());
}
for (CodegenProperty var : fields) {
final String target = directModelReference(var);
if (target != null && reachesModel(target, to, models, visited)) {
return true;
}
}
return false;
}

@Override
public void postProcessFile(File file, String fileType) {
super.postProcessFile(file, fileType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1111,15 +1111,15 @@ pub struct {{{classname}}} {
{{/isNullable}}
{{/hasValidation}}
{{#required}}
pub {{{name}}}: {{#isNullable}}Nullable<{{/isNullable}}{{{dataType}}}{{#isNullable}}>{{/isNullable}},
pub {{{name}}}: {{#isNullable}}Nullable<{{/isNullable}}{{#vendorExtensions.x-is-boxed}}Box<{{/vendorExtensions.x-is-boxed}}{{{dataType}}}{{#vendorExtensions.x-is-boxed}}>{{/vendorExtensions.x-is-boxed}}{{#isNullable}}>{{/isNullable}},
{{/required}}
{{^required}}
{{#isNullable}}
#[serde(deserialize_with = "deserialize_optional_nullable")]
#[serde(default = "default_optional_nullable")]
{{/isNullable}}
#[serde(skip_serializing_if="Option::is_none")]
pub {{{name}}}: Option<{{#isNullable}}Nullable<{{/isNullable}}{{{dataType}}}{{#isNullable}}>{{/isNullable}}>,
pub {{{name}}}: Option<{{#isNullable}}Nullable<{{/isNullable}}{{#vendorExtensions.x-is-boxed}}Box<{{/vendorExtensions.x-is-boxed}}{{{dataType}}}{{#vendorExtensions.x-is-boxed}}>{{/vendorExtensions.x-is-boxed}}{{#isNullable}}>{{/isNullable}}>,
{{/required}}

{{/vars}}
Expand Down Expand Up @@ -1187,7 +1187,7 @@ fn validate_byte_{{#lambda.lowercase}}{{{classname}}}_{{{name}}}{{/lambda.lowerc

impl {{{classname}}} {
#[allow(clippy::new_without_default, clippy::too_many_arguments)]
pub fn new({{#vars}}{{^isDiscriminator}}{{^defaultValue}}{{{name}}}: {{#isNullable}}Nullable<{{/isNullable}}{{{dataType}}}{{#isNullable}}>{{/isNullable}}, {{/defaultValue}}{{/isDiscriminator}}{{#isDiscriminator}}{{#vendorExtensions.x-discriminator-multi}}{{{name}}}: {{{dataType}}}, {{/vendorExtensions.x-discriminator-multi}}{{^vendorExtensions.x-discriminator-multi}}{{^isString}}{{^vendorExtensions.x-discriminator-enum-variant}}{{^defaultValue}}{{{name}}}: {{#isNullable}}Nullable<{{/isNullable}}{{{dataType}}}{{#isNullable}}>{{/isNullable}}, {{/defaultValue}}{{/vendorExtensions.x-discriminator-enum-variant}}{{/isString}}{{/vendorExtensions.x-discriminator-multi}}{{/isDiscriminator}}{{/vars}}) -> {{{classname}}} {
pub fn new({{#vars}}{{^isDiscriminator}}{{^defaultValue}}{{{name}}}: {{#isNullable}}Nullable<{{/isNullable}}{{#vendorExtensions.x-is-boxed}}Box<{{/vendorExtensions.x-is-boxed}}{{{dataType}}}{{#vendorExtensions.x-is-boxed}}>{{/vendorExtensions.x-is-boxed}}{{#isNullable}}>{{/isNullable}}, {{/defaultValue}}{{/isDiscriminator}}{{#isDiscriminator}}{{#vendorExtensions.x-discriminator-multi}}{{{name}}}: {{{dataType}}}, {{/vendorExtensions.x-discriminator-multi}}{{^vendorExtensions.x-discriminator-multi}}{{^isString}}{{^vendorExtensions.x-discriminator-enum-variant}}{{^defaultValue}}{{{name}}}: {{#isNullable}}Nullable<{{/isNullable}}{{{dataType}}}{{#isNullable}}>{{/isNullable}}, {{/defaultValue}}{{/vendorExtensions.x-discriminator-enum-variant}}{{/isString}}{{/vendorExtensions.x-discriminator-multi}}{{/isDiscriminator}}{{/vars}}) -> {{{classname}}} {
{{{classname}}} {
{{#vars}} {{^isDiscriminator}}{{#defaultValue}}{{{name}}}: {{{defaultValue}}}{{/defaultValue}}{{^defaultValue}}{{{name}}}{{/defaultValue}}{{/isDiscriminator}}{{#isDiscriminator}}{{#vendorExtensions.x-discriminator-multi}}{{{name}}}{{/vendorExtensions.x-discriminator-multi}}{{^vendorExtensions.x-discriminator-multi}}{{#isString}}{{{name}}}: Self::_name_for_{{{name}}}(){{/isString}}{{^isString}}{{#vendorExtensions.x-discriminator-enum-variant}}{{{name}}}: Self::_name_for_{{{name}}}(){{/vendorExtensions.x-discriminator-enum-variant}}{{^vendorExtensions.x-discriminator-enum-variant}}{{#defaultValue}}{{{name}}}: {{{defaultValue}}}{{/defaultValue}}{{^defaultValue}}{{{name}}}{{/defaultValue}}{{/vendorExtensions.x-discriminator-enum-variant}}{{/isString}}{{/vendorExtensions.x-discriminator-multi}}{{/isDiscriminator}},
{{/vars}}
Expand Down Expand Up @@ -1335,7 +1335,7 @@ impl std::str::FromStr for {{{classname}}} {
{{{name}}}: std::result::Result::Err("Nullable types not supported in {{{classname}}}".to_string())?,
{{/isNullable}}
{{^isNullable}}
{{{name}}}: intermediate_rep.{{{name}}}.into_iter().next(){{#required}}.ok_or_else(|| "{{{baseName}}} missing in {{{classname}}}".to_string())?{{/required}},
{{{name}}}: intermediate_rep.{{{name}}}.into_iter().next(){{#vendorExtensions.x-is-boxed}}.map(Box::new){{/vendorExtensions.x-is-boxed}}{{#required}}.ok_or_else(|| "{{{baseName}}} missing in {{{classname}}}".to_string())?{{/required}},
{{/isNullable}}
{{/vars}}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,54 @@ public void testDiscriminatorReferencingEnum() throws IOException {
TestUtils.assertFileContains(modelsPath, "String::from(\"car\")");
TestUtils.assertFileContains(Path.of(target.toString(), "/src/types.rs"), "macro_rules! impl_deserialize_tagged");
}

@Test
public void testRecursiveModelFieldsAreBoxed() throws IOException {
Path target = Files.createTempDirectory("test");
final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("rust-axum")
.setInputSpec("src/test/resources/3_1/rust-axum/rust-axum-recursive-models.yaml")
.addAdditionalProperty("boxRecursiveModels", true)
.setSkipOverwrite(false)
.setOutputDir(target.toAbsolutePath().toString().replace("\\", "/"));
List<File> files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate();
files.forEach(File::deleteOnExit);

Path modelsPath = Path.of(target.toString(), "/src/models.rs");
TestUtils.assertFileExists(modelsPath);
// Direct self reference
TestUtils.assertFileContains(modelsPath, "pub parent: Option<Box<models::Node>>,");
TestUtils.assertFileContains(modelsPath, "parent: intermediate_rep.parent.into_iter().next().map(Box::new),");
// Containers already add indirection
TestUtils.assertFileContains(modelsPath, "pub children: Option<Vec<models::Node>>,");
// Nullable self reference (anyOf/oneOf with null)
TestUtils.assertFileContains(modelsPath, "pub manager: Nullable<Box<models::Employee>>,");
TestUtils.assertFileContains(modelsPath, "pub mentor: Option<Nullable<Box<models::Employee>>>,");
TestUtils.assertFileContains(modelsPath, "pub fn new(name: String, manager: Nullable<Box<models::Employee>>, ) -> Employee {");
// Indirect cycle Employee -> Department -> Employee
TestUtils.assertFileContains(modelsPath, "pub department: Option<Box<models::Department>>,");
TestUtils.assertFileContains(modelsPath, "pub head: Option<Box<models::Employee>>,");
// Cycle through a oneOf variant Circle -> Shape -> Circle
TestUtils.assertFileContains(modelsPath, "pub inner: Option<Box<models::Shape>>,");
// Non-recursive references are left as they are
TestUtils.assertFileContains(modelsPath, "pub owner: Option<models::Owner>,");
}

@Test
public void testRecursiveModelFieldsAreNotBoxedByDefault() throws IOException {
Path target = Files.createTempDirectory("test");
final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("rust-axum")
.setInputSpec("src/test/resources/3_1/rust-axum/rust-axum-recursive-models.yaml")
.setSkipOverwrite(false)
.setOutputDir(target.toAbsolutePath().toString().replace("\\", "/"));
List<File> files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate();
files.forEach(File::deleteOnExit);

Path modelsPath = Path.of(target.toString(), "/src/models.rs");
TestUtils.assertFileExists(modelsPath);
TestUtils.assertFileContains(modelsPath, "pub parent: Option<models::Node>,");
TestUtils.assertFileContains(modelsPath, "pub manager: Nullable<models::Employee>,");
TestUtils.assertFileNotContains(modelsPath, "Box<models::");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
openapi: 3.1.0
info:
title: Recursive models
description: Models that reference themselves, directly or through other models
version: 1.0.0
paths:
/nodes:
get:
operationId: getNode
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Node'
/employees:
get:
operationId: getEmployee
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Employee'
/pets:
get:
operationId: getPet
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Pet'
/shapes:
get:
operationId: getShape
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Shape'
components:
schemas:
Node:
type: object
required:
- name
properties:
name:
type: string
parent:
$ref: '#/components/schemas/Node'
children:
type: array
items:
$ref: '#/components/schemas/Node'
Employee:
type: object
required:
- name
- manager
properties:
name:
type: string
manager:
anyOf:
- $ref: '#/components/schemas/Employee'
- type: 'null'
mentor:
oneOf:
- $ref: '#/components/schemas/Employee'
- type: 'null'
department:
$ref: '#/components/schemas/Department'
Department:
type: object
properties:
name:
type: string
head:
$ref: '#/components/schemas/Employee'
Pet:
type: object
properties:
name:
type: string
owner:
$ref: '#/components/schemas/Owner'
Owner:
type: object
properties:
name:
type: string
Shape:
oneOf:
- $ref: '#/components/schemas/Circle'
- $ref: '#/components/schemas/Square'
Circle:
type: object
properties:
radius:
type: number
inner:
$ref: '#/components/schemas/Shape'
Square:
type: object
properties:
side:
type: number
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
target
Cargo.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator

# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.

# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs

# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux

# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux

# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.gitignore
Cargo.toml
README.md
src/apis/default.rs
src/apis/mod.rs
src/header.rs
src/lib.rs
src/models.rs
src/server/mod.rs
src/types.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
7.27.0-SNAPSHOT
Loading
Loading