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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dist/

# Rust
target/
output/

# Node (Prettier / Commitlint installed in CI)
node_modules/
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ generate:
std-http-server: true # also emit an axum server interface
```

Unknown keys (e.g. `output-options`, `import-mapping`) are accepted and ignored
so existing `oapi-codegen` configs can be reused.
`import-mapping` maps a referenced spec file to the Rust module its schemas are
emitted into, so cross-file `$ref`s in the server interface resolve to
`that_module::TypeName` (see the server section below). Other unknown keys (e.g.
`output-options`) are accepted and ignored so existing `oapi-codegen` configs can
be reused.

## How it works

Expand Down Expand Up @@ -90,11 +93,16 @@ handled, it only describes the contract:
You implement `Api` for your own type and pass it to `router`; the generated
code owns extraction, status codes, and JSON (de)serialization.

This is a deliberate first slice. Currently supported: path parameters, JSON
request bodies (a `$ref` or a scalar), and responses keyed by explicit status
codes. Query, header, and cookie parameters are **ignored**. A `default` or
range (`5XX`) response, a component-level `$ref` parameter/body/response, or a
non-scalar path parameter is **rejected** with an error rather than mis-generated.
This is a deliberate first slice. Currently supported: path parameters (inline
scalars, or a same-document `$ref` that resolves to a scalar), JSON request
bodies (a `$ref` or a scalar), and responses keyed by explicit status codes —
including component `$ref` responses (`#/components/responses/...`) resolved
against the document. Cross-file schema `$ref`s in bodies and responses are
routed through `import-mapping` to an external module type (e.g.
`crate::apimodel::Widget`). Query, header, and cookie parameters are **ignored**.
A `default` or range (`5XX`) response, a component-level `$ref` _parameter_ or
_request body_, a cross-file component-_response_ `$ref`, or a non-scalar path
parameter is **rejected** with an error rather than mis-generated.

## Coverage

Expand Down
10 changes: 10 additions & 0 deletions crates/oapi-codegen/src/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,16 @@ fn emit_type(ty: &RustType) -> Result<TokenStream> {
let ident = to_ident(name, Case::Pascal).to_token();
quote! { #ident }
}
RustType::External { module, name } => {
let path: syn::Path = syn::parse_str(module).map_err(|_| {
return Error::UnsupportedSchema {
path: "import-mapping".to_owned(),
reason: format!("module path `{module}` is not a valid Rust path expression"),
};
})?;
let ident = to_ident(name, Case::Pascal).to_token();
quote! { #path::#ident }
}
RustType::Verbatim(text) => {
let parsed: TokenStream = text.parse().map_err(|_| {
return Error::UnsupportedSchema {
Expand Down
2 changes: 2 additions & 0 deletions crates/oapi-codegen/src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ pub enum RustType {
Option(Box<RustType>),
/// A reference to a named (generated or external) type.
Named(String),
/// A reference to a type from an import-mapped module: `module::Name`.
External { module: String, name: String },
/// A verbatim type expression from an `x-rust-type` extension.
Verbatim(String),
}
Expand Down
2 changes: 1 addition & 1 deletion crates/oapi-codegen/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub fn generate(spec_path: &Path, config: &Config) -> Result<String> {
Module::default()
};
if want_server {
let service = paths::generate_service(&spec)?;
let service = paths::generate_service(&spec, &config.import_mapping)?;
return emit::emit_with_service(&module, &service);
}
return emit::emit_module(&module);
Expand Down
93 changes: 87 additions & 6 deletions crates/oapi-codegen/src/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use std::path::PathBuf;
use indexmap::IndexMap;
use openapiv3::OpenAPI;
use openapiv3::ReferenceOr;
use openapiv3::Response;
use openapiv3::Schema;

use crate::error::Error;
Expand Down Expand Up @@ -74,6 +75,43 @@ impl Spec {
return &self.inner.paths;
}

/// Resolve a `#/components/responses/<name>` reference to the concrete
/// component response it names, following same-document reference chains.
pub fn resolve_response(&self, reference: &str) -> Result<&Response> {
let mut current = reference.to_owned();
for _ in 0..MAX_REF_DEPTH {
if ref_file_part(&current).is_some() {
return Err(Error::UnsupportedRef {
reference: current.clone(),
reason: "cross-file component response `$ref`s are not supported".to_owned(),
});
}
let name = ref_component_name(&current, "responses").ok_or_else(|| {
return Error::UnsupportedRef {
reference: current.clone(),
reason: "only `#/components/responses/<name>` references are supported".to_owned(),
};
})?;
Comment thread
Copilot marked this conversation as resolved.
let entry = self
.inner
.components
.as_ref()
.and_then(|components| {
return components.responses.get(name);
})
.ok_or_else(|| return Error::UnresolvedRef(current.clone()))?;
match entry {
ReferenceOr::Item(response) => {
return Ok(response);
}
ReferenceOr::Reference { reference } => {
current = reference.clone();
}
}
}
return Err(Error::UnresolvedRef(reference.to_owned()));
}

/// Resolve a `$ref` string to the concrete schema it names, following
/// chains of references within this document.
pub fn resolve(&self, reference: &str) -> Result<&Schema> {
Expand Down Expand Up @@ -102,30 +140,73 @@ impl Spec {
}
}

/// Extract the trailing schema name from a (possibly cross-file) `$ref`.
/// Extract the trailing schema name from a *same-document* `$ref`
/// (`#/components/schemas/Foo` -> `Foo`).
///
/// `#/components/schemas/Foo` and `schemas/x.yaml#/components/schemas/Foo` both
/// yield `Foo`.
/// Cross-file references (e.g. `schemas/x.yaml#/components/schemas/Foo`) yield
/// `None`: the models pipeline and the same-document `$ref` resolver only handle
/// in-document schemas, so accepting a cross-file name here would risk emitting a
/// local `Named` type for what is actually external. The server generator reads
/// cross-file names via [`ref_component_name`] paired with [`ref_file_part`].
pub fn ref_target_name(reference: &str) -> Option<&str> {
if ref_file_part(reference).is_some() {
return None;
}
return ref_component_name(reference, "schemas");
}

/// Extract the trailing component name of the given `kind` (`schemas` or
/// `responses`) from a (possibly cross-file) `$ref`.
pub fn ref_component_name<'a>(reference: &'a str, kind: &str) -> Option<&'a str> {
let fragment = reference.split('#').nth(1).unwrap_or(reference);
let name = fragment.strip_prefix("/components/schemas/")?;
let prefix = match kind {
"schemas" => "/components/schemas/",
"responses" => "/components/responses/",
_ => return None,
};
let name = fragment.strip_prefix(prefix)?;
if name.is_empty() || name.contains('/') {
return None;
}
return Some(name);
}

/// The file part of a cross-file `$ref` (the text before `#`), or `None` for a
/// same-document reference such as `#/components/schemas/Foo`.
pub fn ref_file_part(reference: &str) -> Option<&str> {
return match reference.split_once('#') {
Some((file, _fragment)) if !file.is_empty() => Some(file),
_ => None,
};
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn extracts_local_and_cross_file_ref_names() {
fn ref_target_name_is_same_document_schemas_only() {
assert_eq!(ref_target_name("#/components/schemas/Foo"), Some("Foo"));
// Cross-file schema refs are rejected here; the server path resolves
// them via `ref_component_name` + `ref_file_part` instead.
assert_eq!(
ref_target_name("schemas/common.yaml#/components/schemas/ErrorResponse"),
Some("ErrorResponse"),
None
);
assert_eq!(ref_target_name("#/components/responses/Bar"), None);
}

#[test]
fn extracts_component_responses_and_file_parts() {
assert_eq!(
ref_component_name("#/components/responses/Bar", "responses"),
Some("Bar")
);
assert_eq!(ref_component_name("#/components/schemas/Foo", "responses"), None);
assert_eq!(ref_file_part("#/components/schemas/Foo"), None);
assert_eq!(
ref_file_part("schemas/common.yaml#/components/schemas/ErrorResponse"),
Some("schemas/common.yaml"),
);
}
}
2 changes: 1 addition & 1 deletion crates/oapi-codegen/src/naming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ mod tests {
("ErrorResponse", "ErrorResponse", false),
("payment_form", "PaymentForm", false),
("da", "Da", false),
("MACHSIKE_SIGNUP_REQUEST", "MachsikeSignupRequest", false),
("PET_SHOP_SIGNUP_REQUEST", "PetShopSignupRequest", false),
];
for (input, expected, raw) in cases {
let ident = to_ident(input, Case::Pascal);
Expand Down
Loading