Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/vast-kids-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@redocly/cli': minor
'@redocly/openapi-core': minor
---

Added a `strategy` option to the `component-name-unique` rule, matching the `--component-names-strategy` option of the `bundle` command.
Set it to `title` to check the component names that bundling derives from each schema's `title`.
With `strategy: title`, the rule also reports referenced schemas that have no `title`, because `bundle` can't name those and fails.
3 changes: 3 additions & 0 deletions docs/@v2/commands/bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,6 @@ All other characters, including non-ASCII letters such as `é` or `я`, are repl
Schemas without `title` can't be named using the `--component-names-strategy=title` strategy.
The bundling process reports an error for such schemas.
{% /admonition %}

To catch name collisions before bundling, set the matching `strategy` option on the
[`component-name-unique`](../rules/oas/component-name-unique.md) rule.
19 changes: 19 additions & 0 deletions docs/@v2/rules/oas/component-name-unique.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ This clearly is not optimal. Having unique component names prevents these proble
| parameters | string | Possible values: `off`, `warn`, `error`. Default: not set. |
| responses | string | Possible values: `off`, `warn`, `error`. Default: not set. |
| requestBodies | string | Possible values: `off`, `warn`, `error`. Default: not set. |
| strategy | string | Possible values: `basename`, `title`. Default: `basename`. |

An example configuration:

Expand All @@ -48,8 +49,26 @@ rules:
parameters: off
responses: warn
requestBodies: warn
strategy: basename
```

### Component names strategy

The rule predicts the component names that `bundle` produces, so `strategy` must match the
[`--component-names-strategy`](../../commands/bundle.md#configure-the-component-names-strategy) option you bundle with.

With the default `basename`, a schema pulled in from another file is named after the `$ref` fragment or the file name.
Two files both called `Order.yaml` therefore collide, and the rule reports them.

With `title`, the same schemas are named after their `title` field instead.
Two files called `Order.yaml` with the titles `Order model` and `Order request` become `OrderModel` and `OrderRequest`, so the rule no longer reports them.
Two schemas in differently named files that share a title do collide, and the rule reports those instead.

The `title` strategy applies to every schema that `bundle` renames, which is every schema reached by a `$ref` that crosses a file boundary.
A referenced schema that has no `title` can't be named under this strategy, and `bundle` fails without producing a file.
The rule reports those schemas so you find them before bundling.
For the uniqueness check itself, such a schema still falls back to its file name, so a name collision is reported as well.

## Examples

Given this configuration:
Expand Down
14 changes: 5 additions & 9 deletions packages/core/src/bundle/bundle-visitor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type RuleSeverity } from '../config/types.js';
import { COMPONENT_NAME_CHARS, type SpecMajorVersion } from '../oas-types.js';
import { type SpecMajorVersion } from '../oas-types.js';
import {
isAbsoluteUrl,
replaceRef,
Expand All @@ -14,11 +14,9 @@ import {
import { type ResolvedRefMap, type Document } from '../resolve.js';
import { reportUnresolvedRef } from '../rules/common/no-unresolved-refs.js';
import { type OasRef, type Oas3Discriminator, type Oas3Example } from '../typings/openapi.js';
import { componentNameFromTitle } from '../utils/component-name-from-title.js';
import { dequal } from '../utils/dequal.js';
import { isPlainObject } from '../utils/is-plain-object.js';
import { isString } from '../utils/is-string.js';
import { makeRefId } from '../utils/make-ref-id.js';
import { toPascalCase } from '../utils/to-pascal-case.js';
import { type Oas3Visitor, type Oas2Visitor } from '../visitors.js';
import { type UserContext, type ResolveResult, type NonUndefined, type Problem } from '../walk.js';
import { type ComponentNamesStrategy } from './bundle-document.js';
Expand Down Expand Up @@ -320,14 +318,12 @@ export function makeBundleVisitor({
return dequal(node, target.node);
}

function componentNameFromTitle(
function resolveComponentNameFromTitle(
target: ComponentTarget,
componentsGroup: ComponentsGroup,
ctx: UserContext
): { key: string; problem?: Problem } {
const { node } = target;
const title = isPlainObject(node) && isString(node.title) ? node.title.trim() : '';
const key = toPascalCase(title).replace(new RegExp(`[^${COMPONENT_NAME_CHARS}]`, 'g'), '-');
const { title, name: key } = componentNameFromTitle(target.node);
const titleLocation = target.location.child('title');

if (title === '') {
Expand Down Expand Up @@ -379,7 +375,7 @@ export function makeBundleVisitor({
const componentsGroup = components[componentType];

if (componentNamesStrategy === 'title' && componentType === schemaComponentType) {
const { key, problem } = componentNameFromTitle(target, componentsGroup, ctx);
const { key, problem } = resolveComponentNameFromTitle(target, componentsGroup, ctx);
if (!problem) {
firstSchemaLocationByName.set(key, target.location.child('title'));
return key;
Expand Down
Loading
Loading