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
79 changes: 79 additions & 0 deletions .github/workflows/renderer-parity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Renderer parity

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
generate:
name: Generate (${{ matrix.renderer }}, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
renderer: [js, native]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.1"

- uses: dtolnay/rust-toolchain@stable
if: matrix.renderer == 'native'

- run: bun install --frozen-lockfile

- run: bun run build:native
if: matrix.renderer == 'native'

- name: Check naming regressions
if: matrix.renderer == 'native'
run: bun run test:parity
env:
OPENAPI_CODEGEN_REQUIRE_FULL_NATIVE: "1"

- name: Generate and hash every output file
run: bun scripts/renderer-parity.ts generate "${{ matrix.renderer }}" "$RUNNER_TEMP/renderer-parity"

- uses: actions/upload-artifact@v4
with:
name: renderer-${{ matrix.renderer }}-${{ matrix.os }}
path: ${{ runner.temp }}/renderer-parity
if-no-files-found: error
retention-days: 7

compare:
name: Compare generated file hashes
needs: generate
if: always()
runs-on: ubuntu-latest
steps:
- name: Require all generation jobs to succeed
if: needs.generate.result != 'success'
run: exit 1

- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.4.1"

- run: bun install --frozen-lockfile

- uses: actions/download-artifact@v4
with:
pattern: renderer-*
path: ${{ runner.temp }}/renderer-artifacts

- name: Require identical file lists and SHA-256 hashes
run: >-
bun scripts/renderer-parity.ts compare "$RUNNER_TEMP/renderer-artifacts"
renderer-js-ubuntu-latest renderer-js-macos-latest
renderer-native-ubuntu-latest renderer-native-macos-latest
3 changes: 2 additions & 1 deletion native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,8 @@ pub fn compile_data(source: String, yaml: bool, options_json: String) -> Result<
schema_owners.insert(name, Value::String(tag));
}
let circular_schemas = compiler.circular_schema_names();
let generated_objects = generated.objects;
let mut generated_objects = compiler.extracted_schema_objects();
generated_objects.extend(generated.objects);
let generated_dependencies = generated.dependencies;
let topology_order: Vec<String> = resolver
.topology_order
Expand Down
17 changes: 10 additions & 7 deletions native/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1771,14 +1771,16 @@ fn endpoint_name(value: &str) -> String {
}

fn snake_to_camel(value: &str) -> String {
// JavaScript snakeToCamel replaces non-overlapping /(_\w)/g matches.
let mut output = String::new();
let mut uppercase = false;
for character in value.chars() {
if character == '_' || character == '-' || character == ' ' {
uppercase = true;
} else if uppercase {
output.extend(character.to_uppercase());
uppercase = false;
let mut characters = value.chars().peekable();
while let Some(character) = characters.next() {
if character == '_'
&& characters
.peek()
.is_some_and(|next| next.is_ascii_alphanumeric() || *next == '_')
{
output.push(characters.next().unwrap().to_ascii_uppercase());
} else {
output.push(character);
}
Expand Down Expand Up @@ -3255,6 +3257,7 @@ fn parameter_description(parameter: &Value) -> String {
.or_else(|| parameter.get("bodyObject"))
.and_then(|object| object.get("description"))
.and_then(Value::as_str)
.filter(|description| !description.is_empty())
{
parts.push(description.to_string());
}
Expand Down
115 changes: 92 additions & 23 deletions native/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,29 +480,85 @@ fn index_operations<'a>(
Ok(result)
}

#[derive(Default)]
struct OperationNameIndex {
stable_counts: HashMap<String, usize>,
path_source_count: usize,
reserved_fallback_count: usize,
}

fn assign_unique_names(operations: &mut [IndexedOperation<'_>], options: &GenerateOptions) {
let mut counts: HashMap<(String, String), usize> = HashMap::default();
for operation in operations.iter() {
let name = operation_name(operation, options, false, false);
*counts.entry((operation.tag.clone(), name)).or_default() += 1;
}
let mut tag_counts: HashMap<(String, String), usize> = HashMap::default();
// Match the JavaScript index: path-derived names are evaluated in the current
// operation's context, not at each candidate's own path.
let mut indexes: HashMap<String, [OperationNameIndex; 2]> = HashMap::default();
for operation in operations.iter() {
let name = operation_name(operation, options, true, false);
*tag_counts.entry((operation.tag.clone(), name)).or_default() += 1;
let tag_indexes = indexes.entry(operation.tag.clone()).or_default();
for (index, keep_tag) in tag_indexes.iter_mut().zip([false, true]) {
if operation
.operation
.get("operationId")
.and_then(Value::as_str)
.unwrap_or_default()
.is_empty()
{
index.path_source_count += 1;
continue;
}
let context = IndexedOperation {
path: "",
method: "",
operation: operation.operation,
tag: operation.tag.clone(),
name: String::new(),
path_parameters: None,
};
let (name, reserved_fallback) =
resolve_operation_name(&context, options, keep_tag, false);
if reserved_fallback {
index.reserved_fallback_count += 1;
} else {
*index.stable_counts.entry(name).or_default() += 1;
}
}
}
let empty_operation = Map::new();
for operation in operations.iter_mut() {
let short = operation_name(operation, options, false, false);
operation.name = if counts.get(&(operation.tag.clone(), short.clone())) == Some(&1) {
short
} else {
let tagged = operation_name(operation, options, true, false);
if tag_counts.get(&(operation.tag.clone(), tagged.clone())) == Some(&1) {
tagged
} else {
operation_name(operation, options, true, true)
let mut unique = None;
for (index, keep_tag) in indexes[&operation.tag].iter().zip([false, true]) {
let name = operation_name(operation, options, keep_tag, false);
let path_context = IndexedOperation {
path: operation.path,
method: operation.method,
operation: &empty_operation,
tag: operation.tag.clone(),
name: String::new(),
path_parameters: None,
};
let path_matches = index.path_source_count > 0
&& operation_name(&path_context, options, keep_tag, false) == name;
let reserved_matches = index.reserved_fallback_count > 0
&& format!(
"{}{}",
operation.method,
path_to_variable_name(operation.path)
) == name;
let count = index.stable_counts.get(&name).copied().unwrap_or_default()
+ if path_matches {
index.path_source_count
} else {
0
}
+ if reserved_matches {
index.reserved_fallback_count
} else {
0
};
if count == 1 {
unique = Some(name);
break;
}
};
}
operation.name = unique.unwrap_or_else(|| operation_name(operation, options, true, true));
}
}

Expand All @@ -512,10 +568,20 @@ fn operation_name(
keep_tag: bool,
keep_prefix: bool,
) -> String {
resolve_operation_name(operation, options, keep_tag, keep_prefix).0
}

fn resolve_operation_name(
operation: &IndexedOperation<'_>,
options: &GenerateOptions,
keep_tag: bool,
keep_prefix: bool,
) -> (String, bool) {
let mut name = operation
.operation
.get("operationId")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.map(invalid_identifier)
.unwrap_or_else(|| {
format!(
Expand Down Expand Up @@ -550,13 +616,16 @@ fn operation_name(
}
}
if RESERVED.contains(&name.as_str()) {
format!(
"{}{}",
operation.method,
path_to_variable_name(operation.path)
(
format!(
"{}{}",
operation.method,
path_to_variable_name(operation.path)
),
true,
)
} else {
name
(name, false)
}
}

Expand Down
Loading
Loading