From c82af194a4cb72415997ed47ab0c3be449c55fec Mon Sep 17 00:00:00 2001 From: Tim Haselaars Date: Mon, 4 May 2026 10:22:18 +0200 Subject: [PATCH 1/4] 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 84101fa..31faf66 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 fbf69e8..57901e4 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 82ddc6d..c122e7c 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 d61ecae..9587c72 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 e842441..31bcaa8 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 4c86767..5a94116 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 8fd9cc0..d5651ea 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 14a8e59..9b9e1e8 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 71ccc07..29ff985 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 69200b3..4d26287 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/4] 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 c798a79..c52f7e4 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 31bcaa8..27f7bd5 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/4] 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 c122e7c..f0bfa53 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`. From c920a302b23cb14e12a40b787f73d9382144c387 Mon Sep 17 00:00:00 2001 From: Tim Haselaars Date: Sat, 23 May 2026 12:46:03 +0200 Subject: [PATCH 4/4] Casing - Configure characters to keep --- CHANGELOG.md | 2 +- openapi-format.js | 3 ++- readme.md | 20 ++++++++++------ test/openapi-core.test.js | 49 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c52f7e4..2113396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## unreleased -- CLI: Fix YAML output to preserve x-version number formatting - Casing - Configure characters to keep +- CLI: Fix YAML output to preserve x-version number formatting ## [1.31.0] - 2026-04-12 diff --git a/openapi-format.js b/openapi-format.js index 31faf66..5f2ee0a 100644 --- a/openapi-format.js +++ b/openapi-format.js @@ -944,13 +944,14 @@ async function openapiChangeCase(oaObj, options) { const parameterFoundIn = orgObj[key].in; if (orgObj[key].in && changeCasingKeyPlans.hasOwnProperty(parameterFoundIn)) { const changeCasingKeyPlan = changeCasingKeyPlans[parameterFoundIn]; - if (changeCasingKeyPlan) { + if (changeCasingKeyPlan && changeCasingKeyPlan.case) { // debugCasingStep = `Casing - components/parameters - in:${parameterFoundIn} - key` const newKey = changeCase(key, changeCasingKeyPlan.case, changeCasingKeyPlan.keep); comps.parameters[key] = newKey; return {[newKey]: orgObj[key]}; } } + return {[key]: orgObj[key]}; }); this.update(Object.assign({}, ...replacedItems)); } diff --git a/readme.md b/readme.md index 57901e4..831ec8f 100644 --- a/readme.md +++ b/readme.md @@ -1356,17 +1356,23 @@ 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. +For every casing option, you can optionally define a matching `*KeepChars` option. This follows the same per-element configuration model as the casing rules themselves: different OpenAPI elements can have different casing conventions, and they may also need to preserve different separator characters. For example, query parameters may preserve dots for nested parameter names, while component schemas may preserve dots for versioned names. + +- `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. -- `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 +- `properties`: Sets the casing for properties within schemas. Example: `camelCase`. +- `propertiesKeepChars`: Keeps selected characters while casing schema properties. Example: `.` for dotted property names. +- `parametersQuery`, `parametersHeader`, `parametersPath`, `parametersCookie`: Define different casing styles for parameters based on their location. Example: `camelCase` for query parameters, `kebab-case` for headers, or `snake_case` for path parameters. +- `parametersQueryKeepChars`, `parametersHeaderKeepChars`, `parametersPathKeepChars`, `parametersCookieKeepChars`: Keep selected characters while casing inline parameter names. +- `componentsSchemas`, `componentsExamples`, `componentsHeaders`, `componentsResponses`, `componentsRequestBodies`, `componentsSecuritySchemes`: Define casing styles for component keys. +- `componentsSchemasKeepChars`, `componentsExamplesKeepChars`, `componentsHeadersKeepChars`, `componentsResponsesKeepChars`, `componentsRequestBodiesKeepChars`, `componentsSecuritySchemesKeepChars`: Keep selected characters while casing component keys. +- `componentsParametersQuery`, `componentsParametersHeader`, `componentsParametersPath`, `componentsParametersCookie`: Define casing styles for reusable parameters in `components.parameters`, based on their `in` value. +- `componentsParametersQueryKeepChars`, `componentsParametersHeaderKeepChars`, `componentsParametersPathKeepChars`, `componentsParametersCookieKeepChars`: Keep selected characters while casing reusable parameter keys in `components.parameters`. See [OpenAPI formatting configuration options](#openapi-formatting-configuration-options) for the full list of casing options +Note: When custom keep characters are configured, they are merged with the default characters that `openapi-format` already preserves: `$` and `@`. To disable the default preserved characters for direct `changeCase` usage, pass an empty array as the custom keep characters. + ## CLI Bundle & Split usage - **Bundling**: Create a self-contained OpenAPI file that can be used for documentation generation or API validation tools that don't support external references. diff --git a/test/openapi-core.test.js b/test/openapi-core.test.js index 27f7bd5..529b29b 100644 --- a/test/openapi-core.test.js +++ b/test/openapi-core.test.js @@ -228,6 +228,55 @@ describe('openapi-format core API', () => { 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'); }); + + it('openapiChangeCase should leave unconfigured component parameter keys unchanged', async () => { + const doc = { + openapi: '3.0.0', + info: {title: 'API', version: '1.0.0'}, + paths: { + '/pets': { + get: { + parameters: [ + { + $ref: '#/components/parameters/Cursor.v1' + }, + { + $ref: '#/components/parameters/X-Request-Id' + } + ], + responses: {200: {description: 'ok'}} + } + } + }, + components: { + parameters: { + 'Cursor.v1': { + name: 'cursor.v1', + in: 'query', + schema: {type: 'string'} + }, + 'X-Request-Id': { + name: 'X-Request-Id', + in: 'header', + schema: {type: 'string'} + } + } + } + }; + + const result = await openapiChangeCase(doc, { + casingSet: { + componentsParametersQuery: 'camelCase', + componentsParametersQueryKeepChars: ['.'] + } + }); + + expect(Object.keys(result.data.components.parameters)).toContain('cursor.v1'); + expect(Object.keys(result.data.components.parameters)).toContain('X-Request-Id'); + + expect(result.data.paths['/pets'].get.parameters[0].$ref).toBe('#/components/parameters/cursor.v1'); + expect(result.data.paths['/pets'].get.parameters[1].$ref).toBe('#/components/parameters/X-Request-Id'); + }); }); describe('openapiSplit API', () => {