From c82af194a4cb72415997ed47ab0c3be449c55fec Mon Sep 17 00:00:00 2001 From: Tim Haselaars Date: Mon, 4 May 2026 10:22:18 +0200 Subject: [PATCH 1/3] Casing - Configure characters to keep --- openapi-format.js | 101 ++++++++++++------ readme.md | 16 ++- .../references/feature-matrix.md | 8 ++ test/casing.test.js | 11 +- test/openapi-core.test.js | 30 ++++++ test/yaml-casing-parameters/customCasing.yaml | 4 +- test/yaml-casing-parameters/input.yaml | 2 +- test/yaml-casing-parameters/output.yaml | 6 +- types/openapi-format.d.ts | 24 ++++- utils/casing.js | 40 ++++++- 10 files changed, 195 insertions(+), 47 deletions(-) diff --git a/openapi-format.js b/openapi-format.js index 84101fae..31faf667 100644 --- a/openapi-format.js +++ b/openapi-format.js @@ -889,23 +889,24 @@ async function openapiChangeCase(oaObj, options) { let jsonObj = JSON.parse(JSON.stringify(oaObj)); // Deep copy of the schema object let defaultCasing = {}; // JSON.parse(fs.readFileSync(__dirname + "/defaultFilter.json", 'utf8')) let casingSet = Object.assign({}, defaultCasing, options.casingSet); + const getKeepChars = target => casingSet[`${target}KeepChars`]; let debugCasingStep = ''; // uncomment // debugCasingStep below to see which sort part is triggered // Could add a default for all types pretty easily. const changeCasingKeyPlans = { - query: casingSet.componentsParametersQuery, - path: casingSet.componentsParametersPath, - header: casingSet.componentsParametersHeader, - cookie: casingSet.componentsParametersCookie + query: {case: casingSet.componentsParametersQuery, keep: getKeepChars('componentsParametersQuery')}, + path: {case: casingSet.componentsParametersPath, keep: getKeepChars('componentsParametersPath')}, + header: {case: casingSet.componentsParametersHeader, keep: getKeepChars('componentsParametersHeader')}, + cookie: {case: casingSet.componentsParametersCookie, keep: getKeepChars('componentsParametersCookie')} }; // Could add a default for all types pretty easily. const changeCasingNamePlans = { - query: casingSet.parametersQuery, - path: casingSet.parametersPath, - header: casingSet.parametersHeader, - cookie: casingSet.parametersCookie + query: {case: casingSet.parametersQuery, keep: getKeepChars('parametersQuery')}, + path: {case: casingSet.parametersPath, keep: getKeepChars('parametersPath')}, + header: {case: casingSet.parametersHeader, keep: getKeepChars('parametersHeader')}, + cookie: {case: casingSet.parametersCookie, keep: getKeepChars('parametersCookie')} }; // Initiate components tracking @@ -920,17 +921,17 @@ async function openapiChangeCase(oaObj, options) { // Change components/schemas - names if (this.path[1] === 'schemas' && this.path.length === 2 && casingSet.componentsSchemas) { // debugCasingStep = 'Casing - components/schemas - names' - this.update(changeObjKeysCase(node, casingSet.componentsSchemas)); + this.update(changeObjKeysCase(node, casingSet.componentsSchemas, getKeepChars('componentsSchemas'))); } // Change components/examples - names if (this.path[1] === 'examples' && this.path.length === 2 && casingSet.componentsExamples) { // debugCasingStep = 'Casing - components/examples - names' - this.update(changeObjKeysCase(node, casingSet.componentsExamples)); + this.update(changeObjKeysCase(node, casingSet.componentsExamples, getKeepChars('componentsExamples'))); } // Change components/headers - names if (this.path[1] === 'headers' && this.path.length === 2 && casingSet.componentsHeaders) { // debugCasingStep = 'Casing - components/headers - names' - this.update(changeObjKeysCase(node, casingSet.componentsHeaders)); + this.update(changeObjKeysCase(node, casingSet.componentsHeaders, getKeepChars('componentsHeaders'))); } // Change components/parameters - in:query/in:headers/in:path/in:cookie - key if ( @@ -945,7 +946,7 @@ async function openapiChangeCase(oaObj, options) { const changeCasingKeyPlan = changeCasingKeyPlans[parameterFoundIn]; if (changeCasingKeyPlan) { // debugCasingStep = `Casing - components/parameters - in:${parameterFoundIn} - key` - const newKey = changeCase(key, changeCasingKeyPlan); + const newKey = changeCase(key, changeCasingKeyPlan.case, changeCasingKeyPlan.keep); comps.parameters[key] = newKey; return {[newKey]: orgObj[key]}; } @@ -957,9 +958,9 @@ async function openapiChangeCase(oaObj, options) { if (this.path[1] === 'parameters' && this.path.length === 3) { if (node.in && changeCasingNamePlans.hasOwnProperty(node.in)) { const changeCasingNamePlan = changeCasingNamePlans[node.in]; - if (changeCasingNamePlan) { + if (changeCasingNamePlan.case) { // debugCasingStep = `Casing - path > parameters/${node.in} - name` - node.name = changeCase(node.name, changeCasingNamePlan); + node.name = changeCase(node.name, changeCasingNamePlan.case, changeCasingNamePlan.keep); this.update(node); } } @@ -967,17 +968,21 @@ async function openapiChangeCase(oaObj, options) { // Change components/responses - names if (this.path[1] === 'responses' && this.path.length === 2 && casingSet.componentsResponses) { // debugCasingStep = 'Casing - components/responses - names' - this.update(changeObjKeysCase(node, casingSet.componentsResponses)); + this.update(changeObjKeysCase(node, casingSet.componentsResponses, getKeepChars('componentsResponses'))); } // Change components/requestBodies - names if (this.path[1] === 'requestBodies' && this.path.length === 2 && casingSet.componentsRequestBodies) { // debugCasingStep = 'Casing - components/requestBodies - names' - this.update(changeObjKeysCase(node, casingSet.componentsRequestBodies)); + this.update( + changeObjKeysCase(node, casingSet.componentsRequestBodies, getKeepChars('componentsRequestBodies')) + ); } // Change components/securitySchemes - names if (this.path[1] === 'securitySchemes' && this.path.length === 2 && casingSet.componentsSecuritySchemes) { // debugCasingStep = 'Casing - components/securitySchemes - names' - this.update(changeObjKeysCase(node, casingSet.componentsSecuritySchemes)); + this.update( + changeObjKeysCase(node, casingSet.componentsSecuritySchemes, getKeepChars('componentsSecuritySchemes')) + ); } } }); @@ -988,15 +993,29 @@ async function openapiChangeCase(oaObj, options) { if (this.key === '$ref') { if (node.startsWith('#/components/schemas/') && casingSet.componentsSchemas) { const compName = node.replace('#/components/schemas/', ''); - this.update(`#/components/schemas/${changeCase(compName, casingSet.componentsSchemas)}`); + this.update( + `#/components/schemas/${changeCase(compName, casingSet.componentsSchemas, getKeepChars('componentsSchemas'))}` + ); } if (node.startsWith('#/components/examples/') && casingSet.componentsExamples) { const compName = node.replace('#/components/examples/', ''); - this.update(`#/components/examples/${changeCase(compName, casingSet.componentsExamples)}`); + this.update( + `#/components/examples/${changeCase( + compName, + casingSet.componentsExamples, + getKeepChars('componentsExamples') + )}` + ); } if (node.startsWith('#/components/responses/') && casingSet.componentsResponses) { const compName = node.replace('#/components/responses/', ''); - this.update(`#/components/responses/${changeCase(compName, casingSet.componentsResponses)}`); + this.update( + `#/components/responses/${changeCase( + compName, + casingSet.componentsResponses, + getKeepChars('componentsResponses') + )}` + ); } if (node.startsWith('#/components/parameters/')) { const compName = node.replace('#/components/parameters/', ''); @@ -1006,37 +1025,51 @@ async function openapiChangeCase(oaObj, options) { } if (node.startsWith('#/components/headers/') && casingSet.componentsHeaders) { const compName = node.replace('#/components/headers/', ''); - this.update(`#/components/headers/${changeCase(compName, casingSet.componentsHeaders)}`); + this.update( + `#/components/headers/${changeCase(compName, casingSet.componentsHeaders, getKeepChars('componentsHeaders'))}` + ); } if (node.startsWith('#/components/requestBodies/') && casingSet.componentsRequestBodies) { const compName = node.replace('#/components/requestBodies/', ''); - this.update(`#/components/requestBodies/${changeCase(compName, casingSet.componentsRequestBodies)}`); + this.update( + `#/components/requestBodies/${changeCase( + compName, + casingSet.componentsRequestBodies, + getKeepChars('componentsRequestBodies') + )}` + ); } if (node.startsWith('#/components/securitySchemes/') && casingSet.componentsSecuritySchemes) { const compName = node.replace('#/components/securitySchemes/', ''); - this.update(`#/components/securitySchemes/${changeCase(compName, casingSet.componentsSecuritySchemes)}`); + this.update( + `#/components/securitySchemes/${changeCase( + compName, + casingSet.componentsSecuritySchemes, + getKeepChars('componentsSecuritySchemes') + )}` + ); } } // Change operationId if (this.key === 'operationId' && casingSet.operationId && this.path[0] === 'paths' && this.path.length === 4) { // debugCasingStep = 'Casing - Single field - OperationId' - this.update(changeCase(node, casingSet.operationId)); + this.update(changeCase(node, casingSet.operationId, getKeepChars('operationId'))); } // Change summary if (this.key === 'summary' && casingSet.summary) { // debugCasingStep = 'Casing - Single field - summary' - this.update(changeCase(node, casingSet.summary)); + this.update(changeCase(node, casingSet.summary, getKeepChars('summary'))); } // Change description if (this.key === 'description' && casingSet.description) { // debugCasingStep = 'Casing - Single field - description' - this.update(changeCase(node, casingSet.description)); + this.update(changeCase(node, casingSet.description, getKeepChars('description'))); } // Change paths > examples - name if (this.path[0] === 'paths' && this.key === 'examples' && casingSet.componentsExamples) { // debugCasingStep = 'Casing - Single field - examples name' - this.update(changeObjKeysCase(node, casingSet.componentsExamples)); + this.update(changeObjKeysCase(node, casingSet.componentsExamples, getKeepChars('componentsExamples'))); } // Change components/schemas - properties if ( @@ -1048,12 +1081,12 @@ async function openapiChangeCase(oaObj, options) { this.parent.key !== 'value' ) { // debugCasingStep = 'Casing - components/schemas - properties name' - this.update(changeObjKeysCase(node, casingSet.properties)); + this.update(changeObjKeysCase(node, casingSet.properties, getKeepChars('properties'))); } // Change components/schemas - required properties if (this.path[1] === 'schemas' && this.parent.key === 'required' && casingSet.properties) { // debugCasingStep = 'Casing - components/schemas - required properties' - this.update(changeCase(node, casingSet.properties)); + this.update(changeCase(node, casingSet.properties, getKeepChars('properties'))); } // Change paths > schema - properties if ( @@ -1065,12 +1098,14 @@ async function openapiChangeCase(oaObj, options) { this.parent.key !== 'value' ) { // debugCasingStep = 'Casing - paths > schema - properties name' - this.update(changeObjKeysCase(node, casingSet.properties)); + this.update(changeObjKeysCase(node, casingSet.properties, getKeepChars('properties'))); } // Change security - keys if (this.path[0] === 'paths' && this.key === 'security' && isArray(node) && casingSet.componentsSecuritySchemes) { // debugCasingStep = 'Casing - path > - security' - this.update(changeArrayObjKeysCase(node, casingSet.componentsSecuritySchemes)); + this.update( + changeArrayObjKeysCase(node, casingSet.componentsSecuritySchemes, getKeepChars('componentsSecuritySchemes')) + ); } // Change parameters - name if (this.path[0] === 'paths' && this.key === 'parameters' && changeParametersCasingEnabled(casingSet)) { @@ -1081,9 +1116,9 @@ async function openapiChangeCase(oaObj, options) { for (let i = 0; i < params.length; i++) { if (params[i].in && changeCasingNamePlans.hasOwnProperty(params[i].in)) { const changeCasingNamePlan = changeCasingNamePlans[params[i].in]; - if (changeCasingNamePlan) { + if (changeCasingNamePlan.case) { // debugCasingStep = 'Casing - path > parameters/query- name' - params[i].name = changeCase(params[i].name, changeCasingNamePlan); + params[i].name = changeCase(params[i].name, changeCasingNamePlan.case, changeCasingNamePlan.keep); } } } diff --git a/readme.md b/readme.md index fbf69e82..57901e4d 100644 --- a/readme.md +++ b/readme.md @@ -1327,11 +1327,17 @@ In this example, the customCasing.yaml file would contain the desired casing pre ```yaml operationId: snake_case +operationIdKeepChars: + - . properties: camelCase +propertiesKeepChars: + - . parametersQuery: kebab-case parametersHeader: kebab-case parametersPath: snake_case +parametersQueryKeepChars: + - . componentsExamples: PascalCase componentsSchemas: camelCase @@ -1343,14 +1349,20 @@ componentsSecuritySchemes: PascalCase componentsParametersQuery: snake_case componentsParametersHeader: kebab-case componentsParametersPath: camelCase +componentsParametersQueryKeepChars: + - . ``` **Casing Options:** In the customCasing.yaml, you can define the casing style for various OpenAPI properties, allowing you to customize the appearance of your document consistently. - `operationId`: Defines the casing for operation IDs. Example: snake_case, PascalCase, or camelCase. +- `operationIdKeepChars`: Keeps selected characters while casing `operationId`. Example: `.` for dotted identifiers. - `properties`: Sets the casing for properties within components. Example: camelCase. -- `parametersQuery`, `parametersHeader`, `parametersPath`: Define different casing styles for parameters based on their location (query, header, path). Example: snake_case, kebab-case. +- `propertiesKeepChars`: Keeps selected characters while casing properties. Example: `.`. +- `parametersQuery`, `parametersHeader`, `parametersPath`, `parametersCookie`: Define different casing styles for parameters based on their location (query, header, path, cookie). Example: snake_case, kebab-case. +- `parametersQueryKeepChars`, `parametersHeaderKeepChars`, `parametersPathKeepChars`, `parametersCookieKeepChars`: Keep selected characters while casing inline parameters. +- `componentsParametersQueryKeepChars`, `componentsParametersHeaderKeepChars`, `componentsParametersPathKeepChars`, `componentsParametersCookieKeepChars`: Keep selected characters while casing referenced parameters in `components.parameters`. - and many more See [OpenAPI formatting configuration options](#openapi-formatting-configuration-options) for the full list of casing options @@ -1713,4 +1725,4 @@ The casing options available in `openapi-format` are powered by the excellent [c JetBrains logo. -Special thanks to [JetBrains](https://www.jetbrains.com/) for their continuous sponsorship of this project over the last 3 years, and for their support to open-source software (OSS) initiatives. \ No newline at end of file +Special thanks to [JetBrains](https://www.jetbrains.com/) for their continuous sponsorship of this project over the last 3 years, and for their support to open-source software (OSS) initiatives. diff --git a/skills/openapi-format/references/feature-matrix.md b/skills/openapi-format/references/feature-matrix.md index 82ddc6d7..c122e7c1 100644 --- a/skills/openapi-format/references/feature-matrix.md +++ b/skills/openapi-format/references/feature-matrix.md @@ -74,13 +74,21 @@ Important behavior: | Option (`casingSet`) | Effect | |---|---| | `operationId` | Casing for operation IDs | +| `operationIdKeepChars` | Extra characters to preserve while casing `operationId` | +| `summary` / `description` | Casing for operation summaries and descriptions | +| `summaryKeepChars` / `descriptionKeepChars` | Extra characters to preserve while casing summaries and descriptions | | `properties` | Casing for schema/path property keys and required names | +| `propertiesKeepChars` | Extra characters to preserve while casing properties | | `parametersQuery` / `parametersHeader` / `parametersPath` / `parametersCookie` | Casing for inline parameter names by location | +| `parametersQueryKeepChars` / `parametersHeaderKeepChars` / `parametersPathKeepChars` / `parametersCookieKeepChars` | Extra characters to preserve while casing inline parameter names | | `componentsSchemas` / `componentsExamples` / `componentsHeaders` / `componentsResponses` / `componentsRequestBodies` / `componentsSecuritySchemes` | Casing for component keys | +| `componentsSchemasKeepChars` / `componentsExamplesKeepChars` / `componentsHeadersKeepChars` / `componentsResponsesKeepChars` / `componentsRequestBodiesKeepChars` / `componentsSecuritySchemesKeepChars` | Extra characters to preserve while casing component keys | | `componentsParametersQuery` / `componentsParametersHeader` / `componentsParametersPath` / `componentsParametersCookie` | Casing for `components.parameters` keys by parameter `in` | +| `componentsParametersQueryKeepChars` / `componentsParametersHeaderKeepChars` / `componentsParametersPathKeepChars` / `componentsParametersCookieKeepChars` | Extra characters to preserve while casing `components.parameters` keys | Reference behavior: - Related `$ref` values are updated for renamed component keys. +- Custom keep chars are merged with the built-in defaults (`$`, `@`). ## Generate diff --git a/test/casing.test.js b/test/casing.test.js index d61ecae2..9587c722 100644 --- a/test/casing.test.js +++ b/test/casing.test.js @@ -165,8 +165,17 @@ describe('openapi-format CLI casing tests', () => { it('casing should keep default @$ characters', async () => { expect(of.changeCase('@openapi-format$}}', 'snake_case')).toBe('@openapi_format$'); }); - it('casing should remove all custom characters', async () => { + it('casing should keep custom dot characters and defaults', async () => { + expect(of.changeCase('cursor.created_at', 'camelCase', ['.'])).toBe('cursor.createdAt'); + }); + it('casing should merge custom characters with built-in defaults', async () => { + expect(of.changeCase('@openapi-$format.v2', 'camelCase', ['.'])).toBe('@openapi$format.v2'); + }); + it('casing should allow callers to disable built-in defaults with an empty array', async () => { expect(of.changeCase('@openapi-$format}}', 'snake_case', [])).toBe('openapi_format'); }); + it('casing should keep default @$ characters when no custom keep chars are provided', async () => { + expect(of.changeCase('@openapi-$format}}', 'snake_case')).toBe('@openapi$format'); + }); }); }); diff --git a/test/openapi-core.test.js b/test/openapi-core.test.js index e8424418..31bcaa8e 100644 --- a/test/openapi-core.test.js +++ b/test/openapi-core.test.js @@ -123,6 +123,36 @@ describe('openapi-format core API', () => { expect(result.data.paths['/pets'].get.summary).toBe('list-pets'); expect(result.data.paths['/pets'].get.description).toBe('returns-all-pets'); }); + + it('openapiChangeCase should keep configured separator characters for parameter names', async () => { + const doc = { + openapi: '3.0.0', + info: {title: 'API', version: '1.0.0'}, + paths: { + '/pets': { + get: { + parameters: [ + { + name: 'cursor.created_at', + in: 'query', + schema: {type: 'string'} + } + ], + responses: {200: {description: 'ok'}} + } + } + } + }; + + const result = await openapiChangeCase(doc, { + casingSet: { + parametersQuery: 'camelCase', + parametersQueryKeepChars: ['.'] + } + }); + + expect(result.data.paths['/pets'].get.parameters[0].name).toBe('cursor.createdAt'); + }); }); describe('openapiSplit API', () => { diff --git a/test/yaml-casing-parameters/customCasing.yaml b/test/yaml-casing-parameters/customCasing.yaml index 4c86767a..5a941165 100644 --- a/test/yaml-casing-parameters/customCasing.yaml +++ b/test/yaml-casing-parameters/customCasing.yaml @@ -1,3 +1,5 @@ -parametersQuery: snake_case +parametersQuery: camelCase +parametersQueryKeepChars: + - . parametersHeader: snake_case parametersPath: snake_case diff --git a/test/yaml-casing-parameters/input.yaml b/test/yaml-casing-parameters/input.yaml index 8fd9cc06..d5651eaa 100644 --- a/test/yaml-casing-parameters/input.yaml +++ b/test/yaml-casing-parameters/input.yaml @@ -121,7 +121,7 @@ paths: schema: type: integer format: int64 - - name: limitItems + - name: cursor.created_at in: query description: ID of pet to return required: true diff --git a/test/yaml-casing-parameters/output.yaml b/test/yaml-casing-parameters/output.yaml index 14a8e599..9b9e1e85 100644 --- a/test/yaml-casing-parameters/output.yaml +++ b/test/yaml-casing-parameters/output.yaml @@ -121,7 +121,7 @@ paths: schema: type: integer format: int64 - - name: limit_items + - name: cursor.createdAt in: query description: ID of pet to return required: true @@ -604,7 +604,7 @@ components: summary: A payload example for a notification parameters: skipParam: - name: skip_items + name: skipItems in: query description: number of items to skip required: true @@ -612,7 +612,7 @@ components: type: integer format: int32 limitParam: - name: $limit_param + name: $limitParam in: query description: max records to return required: true diff --git a/types/openapi-format.d.ts b/types/openapi-format.d.ts index 71ccc071..29ff9857 100644 --- a/types/openapi-format.d.ts +++ b/types/openapi-format.d.ts @@ -69,19 +69,41 @@ declare module 'openapi-format' { export interface OpenAPICasingSet { operationId?: string; + operationIdKeepChars?: string[]; + summary?: string; + summaryKeepChars?: string[]; + description?: string; + descriptionKeepChars?: string[]; properties?: string; + propertiesKeepChars?: string[]; parametersQuery?: string; + parametersQueryKeepChars?: string[]; parametersHeader?: string; + parametersHeaderKeepChars?: string[]; parametersPath?: string; + parametersPathKeepChars?: string[]; + parametersCookie?: string; + parametersCookieKeepChars?: string[]; componentsExamples?: string; + componentsExamplesKeepChars?: string[]; componentsSchemas?: string; + componentsSchemasKeepChars?: string[]; componentsHeaders?: string; + componentsHeadersKeepChars?: string[]; componentsResponses?: string; + componentsResponsesKeepChars?: string[]; componentsRequestBodies?: string; + componentsRequestBodiesKeepChars?: string[]; componentsSecuritySchemes?: string; + componentsSecuritySchemesKeepChars?: string[]; componentsParametersQuery?: string; + componentsParametersQueryKeepChars?: string[]; componentsParametersHeader?: string; + componentsParametersHeaderKeepChars?: string[]; componentsParametersPath?: string; + componentsParametersPathKeepChars?: string[]; + componentsParametersCookie?: string; + componentsParametersCookieKeepChars?: string[]; [key: string]: unknown; } @@ -199,7 +221,7 @@ declare module 'openapi-format' { export function detectFormat(input: string): Promise<'json' | 'yaml' | 'unknown'>; export function analyzeOpenApi(oaObj: OpenApiDocument): AnalyzeOpenApiResult; - export function changeCase(valueAsString: string, caseType: string): string; + export function changeCase(valueAsString: string, caseType: string, customKeepChars?: string[]): string; export function resolveJsonPath(obj: Record | unknown[], path: string): JsonPathNode[]; export function resolveJsonPathValue(obj: Record | unknown[], path: string): unknown[]; } diff --git a/utils/casing.js b/utils/casing.js index 69200b34..4d262870 100644 --- a/utils/casing.js +++ b/utils/casing.js @@ -46,14 +46,15 @@ function changeParametersCasingEnabled(casingSet) { * Change Object keys case function * @param {object} obj * @param {string} caseType + * @param {string[]} customKeepChars Custom characters to keep * @returns {*} */ -function changeObjKeysCase(obj, caseType) { +function changeObjKeysCase(obj, caseType, customKeepChars = null) { if (!isObject(obj)) return obj; const orgObj = JSON.parse(JSON.stringify(obj)); // Deep copy of the object let replacedItems = Object.keys(orgObj).map(key => { - const newKey = changeCase(key, caseType); + const newKey = changeCase(key, caseType, customKeepChars); return {[newKey]: orgObj[key]}; }); return Object.assign({}, ...replacedItems); @@ -63,18 +64,47 @@ function changeObjKeysCase(obj, caseType) { * Change object keys case in array function * @param {object} node * @param {string} caseType + * @param {string[]} customKeepChars Custom characters to keep * @returns {*} */ -function changeArrayObjKeysCase(node, caseType) { +function changeArrayObjKeysCase(node, caseType, customKeepChars = null) { if (!isArray(node)) return node; const casedNode = JSON.parse(JSON.stringify(node)); // Deep copy of the schema object for (let i = 0; i < casedNode.length; i++) { - casedNode[i] = changeObjKeysCase(casedNode[i], caseType); + casedNode[i] = changeObjKeysCase(casedNode[i], caseType, customKeepChars); } return casedNode; } +/** + * Build the keep character list for case-anything. + * Always preserves the built-in defaults and appends any custom characters. + * @param {string[]} customKeepChars Custom characters to keep + * @returns {string[]} + */ +function normalizeKeepChars(customKeepChars = null) { + if (customKeepChars == null) { + return ['$', '@']; + } + + if (!isArray(customKeepChars)) { + return customKeepChars; + } + + if (customKeepChars.length === 0) { + return []; + } + + const keepChars = ['$', '@']; + customKeepChars.forEach(char => { + if (!keepChars.includes(char)) { + keepChars.push(char); + } + }); + return keepChars; +} + /** * Change case function * @param {string} valueAsString The value to change @@ -84,7 +114,7 @@ function changeArrayObjKeysCase(node, caseType) { */ function changeCase(valueAsString, caseType, customKeepChars = null) { if (!isString(valueAsString) || valueAsString === '') return valueAsString; - const keepChars = customKeepChars || ['$', '@']; + const keepChars = normalizeKeepChars(customKeepChars); const cleanedString = valueAsString.replace(/\[(.*?)]/g, (match, p1) => { return ' ' + p1.replace(/([A-Z])/g, ' $1') + ' '; }); From 9858e5760b3824081dc7682a1bd0be3cfbb49919 Mon Sep 17 00:00:00 2001 From: Tim Haselaars Date: Mon, 4 May 2026 10:24:02 +0200 Subject: [PATCH 2/3] Casing - Configure characters to keep --- CHANGELOG.md | 1 + test/openapi-core.test.js | 75 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c798a796..c52f7e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## unreleased - CLI: Fix YAML output to preserve x-version number formatting +- Casing - Configure characters to keep ## [1.31.0] - 2026-04-12 diff --git a/test/openapi-core.test.js b/test/openapi-core.test.js index 31bcaa8e..27f7bd5f 100644 --- a/test/openapi-core.test.js +++ b/test/openapi-core.test.js @@ -153,6 +153,81 @@ describe('openapi-format core API', () => { expect(result.data.paths['/pets'].get.parameters[0].name).toBe('cursor.createdAt'); }); + + it('openapiChangeCase should keep configured separator characters for component schema keys and refs', async () => { + const doc = { + openapi: '3.0.0', + info: {title: 'API', version: '1.0.0'}, + paths: { + '/pets': { + get: { + responses: { + 200: { + description: 'ok', + content: {'application/json': {schema: {$ref: '#/components/schemas/Pet.v1'}}} + } + } + } + } + }, + components: { + schemas: { + 'Pet.v1': {type: 'object'} + } + } + }; + + const result = await openapiChangeCase(doc, { + casingSet: { + componentsSchemas: 'camelCase', + componentsSchemasKeepChars: ['.'] + } + }); + + expect(Object.keys(result.data.components.schemas)).toContain('pet.v1'); + expect(result.data.paths['/pets'].get.responses[200].content['application/json'].schema.$ref).toBe( + '#/components/schemas/pet.v1' + ); + }); + + it('openapiChangeCase should keep configured separator characters for component parameter keys and refs', async () => { + const doc = { + openapi: '3.0.0', + info: {title: 'API', version: '1.0.0'}, + paths: { + '/pets': { + get: { + parameters: [ + { + $ref: '#/components/parameters/Cursor.v1' + } + ], + responses: {200: {description: 'ok'}} + } + } + }, + components: { + parameters: { + 'Cursor.v1': { + name: 'cursor.v1', + in: 'query', + schema: {type: 'string'} + } + } + } + }; + + const result = await openapiChangeCase(doc, { + casingSet: { + componentsParametersQuery: 'camelCase', + componentsParametersQueryKeepChars: ['.'] + } + }); + + expect(Object.keys(result.data.components.parameters)).toContain('cursor.v1'); + expect(result.data.components.parameters['cursor.v1'].name).toBe('cursor.v1'); + expect(result.data.paths['/pets'].get.parameters[0].$ref).toBe('#/components/parameters/cursor.v1'); + }); }); describe('openapiSplit API', () => { From efbaabfe46a5ed4a239d8c06964ac52e2914b049 Mon Sep 17 00:00:00 2001 From: Tim Haselaars Date: Sat, 9 May 2026 13:07:41 +0200 Subject: [PATCH 3/3] Casing - Configure characters to keep --- .../references/feature-matrix.md | 132 +++++++++--------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/skills/openapi-format/references/feature-matrix.md b/skills/openapi-format/references/feature-matrix.md index c122e7c1..f0bfa532 100644 --- a/skills/openapi-format/references/feature-matrix.md +++ b/skills/openapi-format/references/feature-matrix.md @@ -4,20 +4,20 @@ This matrix summarizes CLI/config options, defaults, and notable interactions. ## Input and output -| Option | Type | Default | Effect | Notes | -|---|---|---|---|---| -| `oaFile` | argument | required unless overlay `extends` | Input OpenAPI/AsyncAPI source | Supports local path and remote URL. -| `--output`, `-o` | path | none | Write output to file or split root | Required for `--split`. -| `--json` | boolean | false | Print stdout as JSON | Affects stdout formatting when no output file is given. -| `--yaml` | boolean | false | Print stdout as YAML | Affects stdout formatting when no output file is given. +| Option | Type | Default | Effect | Notes | +|------------------|----------|-----------------------------------|------------------------------------|---------------------------------------------------------| +| `oaFile` | argument | required unless overlay `extends` | Input OpenAPI/AsyncAPI source | Supports local path and remote URL. | +| `--output`, `-o` | path | none | Write output to file or split root | Required for `--split`. | +| `--json` | boolean | false | Print stdout as JSON | Affects stdout formatting when no output file is given. | +| `--yaml` | boolean | false | Print stdout as YAML | Affects stdout formatting when no output file is given. | ## Config loading and precedence -| Source | Applied when | Priority | -|---|---|---| -| `.openapiformatrc` in CWD | only when `--configFile` is absent | lowest | -| `--configFile` | when provided | middle | -| CLI flags | always | highest | +| Source | Applied when | Priority | +|---------------------------|------------------------------------|----------| +| `.openapiformatrc` in CWD | only when `--configFile` is absent | lowest | +| `--configFile` | when provided | middle | +| CLI flags | always | highest | Normalization behavior: - `--no-sort` sets `sort=false`. @@ -33,37 +33,37 @@ Defaults with explicit fallback logic: ## Sorting -| Option | Type | Default | Effect | Notes | -|---|---|---|---|---| -| `--sortFile`, `-s` | path | `defaultSort.json` | Field order priorities | Used when `sort=true`. -| `--no-sort` | boolean | false | Disable sorting | Skips sort stage entirely. -| `--sortComponentsFile` | path | none | Alphabetize listed component groups | See `defaultSortComponents.json`. -| `--sortComponentsProps` | boolean | false | Alphabetize schema properties in components | Scope is `components.schemas.*.properties`. -| `sortSet.sortPathsBy` | enum | `original` | Path order mode | Values: `original`, `path`, `tags`. +| Option | Type | Default | Effect | Notes | +|-------------------------|---------|--------------------|---------------------------------------------|---------------------------------------------| +| `--sortFile`, `-s` | path | `defaultSort.json` | Field order priorities | Used when `sort=true`. | +| `--no-sort` | boolean | false | Disable sorting | Skips sort stage entirely. | +| `--sortComponentsFile` | path | none | Alphabetize listed component groups | See `defaultSortComponents.json`. | +| `--sortComponentsProps` | boolean | false | Alphabetize schema properties in components | Scope is `components.schemas.*.properties`. | +| `sortSet.sortPathsBy` | enum | `original` | Path order mode | Values: `original`, `path`, `tags`. | ## Filtering -| Key | Type | Semantics | -|---|---|---| -| `methods` | string[] | Remove matching HTTP methods from path items | -| `inverseMethods` | string[] | Keep only matching methods | -| `tags` | string[] | Remove operations/tag entries with matching tags | -| `inverseTags` | string[] | Keep operations/tag entries with matching tags | -| `operationIds` | string[] | Remove matching operationIds | -| `inverseOperationIds` | string[] | Keep only matching operationIds | -| `operations` | string[] | Remove matching `path#method` patterns | -| `flags` | string[] | Remove objects containing matching marker keys | -| `inverseFlags` | string[] | Keep objects containing matching marker keys | -| `flagValues` | object[] | Remove objects/values matching key-value markers | -| `inverseFlagValues` | object[] | Keep objects matching marker key-values | -| `responseContent` | string[] | Remove matching response media types | -| `inverseResponseContent` | string[] | Keep only matching response media types | -| `requestContent` | string[] | Remove matching request media types | -| `inverseRequestContent` | string[] | Keep only matching request media types | -| `unusedComponents` | string[] | Remove unreferenced components recursively | -| `stripFlags` | string[] | Remove flag keys after filtering | -| `textReplace` | object[] | Replace text in `description`, `summary`, and `url` | -| `preserveEmptyObjects` | boolean/string[] | Preserve selected empty objects instead of pruning | +| Key | Type | Semantics | +|--------------------------|------------------|-----------------------------------------------------| +| `methods` | string[] | Remove matching HTTP methods from path items | +| `inverseMethods` | string[] | Keep only matching methods | +| `tags` | string[] | Remove operations/tag entries with matching tags | +| `inverseTags` | string[] | Keep operations/tag entries with matching tags | +| `operationIds` | string[] | Remove matching operationIds | +| `inverseOperationIds` | string[] | Keep only matching operationIds | +| `operations` | string[] | Remove matching `path#method` patterns | +| `flags` | string[] | Remove objects containing matching marker keys | +| `inverseFlags` | string[] | Keep objects containing matching marker keys | +| `flagValues` | object[] | Remove objects/values matching key-value markers | +| `inverseFlagValues` | object[] | Keep objects matching marker key-values | +| `responseContent` | string[] | Remove matching response media types | +| `inverseResponseContent` | string[] | Keep only matching response media types | +| `requestContent` | string[] | Remove matching request media types | +| `inverseRequestContent` | string[] | Keep only matching request media types | +| `unusedComponents` | string[] | Remove unreferenced components recursively | +| `stripFlags` | string[] | Remove flag keys after filtering | +| `textReplace` | object[] | Replace text in `description`, `summary`, and `url` | +| `preserveEmptyObjects` | boolean/string[] | Preserve selected empty objects instead of pruning | Important behavior: - Filter stage is recursive and may run multiple passes to remove now-unused components. @@ -71,20 +71,20 @@ Important behavior: ## Casing -| Option (`casingSet`) | Effect | -|---|---| -| `operationId` | Casing for operation IDs | -| `operationIdKeepChars` | Extra characters to preserve while casing `operationId` | -| `summary` / `description` | Casing for operation summaries and descriptions | -| `summaryKeepChars` / `descriptionKeepChars` | Extra characters to preserve while casing summaries and descriptions | -| `properties` | Casing for schema/path property keys and required names | -| `propertiesKeepChars` | Extra characters to preserve while casing properties | -| `parametersQuery` / `parametersHeader` / `parametersPath` / `parametersCookie` | Casing for inline parameter names by location | -| `parametersQueryKeepChars` / `parametersHeaderKeepChars` / `parametersPathKeepChars` / `parametersCookieKeepChars` | Extra characters to preserve while casing inline parameter names | -| `componentsSchemas` / `componentsExamples` / `componentsHeaders` / `componentsResponses` / `componentsRequestBodies` / `componentsSecuritySchemes` | Casing for component keys | -| `componentsSchemasKeepChars` / `componentsExamplesKeepChars` / `componentsHeadersKeepChars` / `componentsResponsesKeepChars` / `componentsRequestBodiesKeepChars` / `componentsSecuritySchemesKeepChars` | Extra characters to preserve while casing component keys | -| `componentsParametersQuery` / `componentsParametersHeader` / `componentsParametersPath` / `componentsParametersCookie` | Casing for `components.parameters` keys by parameter `in` | -| `componentsParametersQueryKeepChars` / `componentsParametersHeaderKeepChars` / `componentsParametersPathKeepChars` / `componentsParametersCookieKeepChars` | Extra characters to preserve while casing `components.parameters` keys | +| Option (`casingSet`) | Effect | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------| +| `operationId` | Casing for operation IDs | +| `operationIdKeepChars` | Extra characters to preserve while casing `operationId` | +| `summary` / `description` | Casing for operation summaries and descriptions | +| `summaryKeepChars` / `descriptionKeepChars` | Extra characters to preserve while casing summaries and descriptions | +| `properties` | Casing for schema/path property keys and required names | +| `propertiesKeepChars` | Extra characters to preserve while casing properties | +| `parametersQuery` / `parametersHeader` / `parametersPath` / `parametersCookie` | Casing for inline parameter names by location | +| `parametersQueryKeepChars` / `parametersHeaderKeepChars` / `parametersPathKeepChars` / `parametersCookieKeepChars` | Extra characters to preserve while casing inline parameter names | +| `componentsSchemas` / `componentsExamples` / `componentsHeaders` / `componentsResponses` / `componentsRequestBodies` / `componentsSecuritySchemes` | Casing for component keys | +| `componentsSchemasKeepChars` / `componentsExamplesKeepChars` / `componentsHeadersKeepChars` / `componentsResponsesKeepChars` / `componentsRequestBodiesKeepChars` / `componentsSecuritySchemesKeepChars` | Extra characters to preserve while casing component keys | +| `componentsParametersQuery` / `componentsParametersHeader` / `componentsParametersPath` / `componentsParametersCookie` | Casing for `components.parameters` keys by parameter `in` | +| `componentsParametersQueryKeepChars` / `componentsParametersHeaderKeepChars` / `componentsParametersPathKeepChars` / `componentsParametersCookieKeepChars` | Extra characters to preserve while casing `components.parameters` keys | Reference behavior: - Related `$ref` values are updated for renamed component keys. @@ -92,17 +92,17 @@ Reference behavior: ## Generate -| Key (`generateSet`) | Type | Default | Effect | -|---|---|---|---| -| `operationIdTemplate` | string | none | Template used to generate operationIds | -| `overwriteExisting` | boolean | false | If true, regenerate even existing operationIds | +| Key (`generateSet`) | Type | Default | Effect | +|-----------------------|---------|---------|------------------------------------------------| +| `operationIdTemplate` | string | none | Template used to generate operationIds | +| `overwriteExisting` | boolean | false | If true, regenerate even existing operationIds | Template resolves against operation context using internal parse template utilities. ## Overlay -| Option | Type | Effect | -|---|---|---| +| Option | Type | Effect | +|-----------------------|------|--------------------------------------------------| | `--overlayFile`, `-l` | path | Load overlay actions and apply to input document | Notable behavior: @@ -112,10 +112,10 @@ Notable behavior: ## Convert and rename -| Option | Type | Effect | -|---|---|---| +| Option | Type | Effect | +|---------------|--------|-------------------------------| | `--convertTo` | string | Convert OpenAPI to 3.1 or 3.2 | -| `--rename` | string | Replace `info.title` | +| `--rename` | string | Replace `info.title` | Convert stage details: - Converts nullable/exclusive limits/example/const forms as needed. @@ -124,10 +124,10 @@ Convert stage details: ## Split and bundle -| Option | Type | Default | Effect | -|---|---|---|---| -| `--split` | boolean | false | Write multi-file output structure | -| `--no-bundle` | boolean | false | Disable local/remote `$ref` bundling during parse | +| Option | Type | Default | Effect | +|---------------|---------|---------|---------------------------------------------------| +| `--split` | boolean | false | Write multi-file output structure | +| `--no-bundle` | boolean | false | Disable local/remote `$ref` bundling during parse | Split behavior: - Requires `--output`.