Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/convert-proto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ jobs:
run: npm ci && npm run preprocessing

- name: Clone Protobuf Generator Repository
run: git clone https://github.com/OpenAPITools/openapi-generator cloned-repo
run: |
git clone https://github.com/OpenAPITools/openapi-generator cloned-repo
cd cloned-repo
git checkout 6699ecd9d2f4e0868f23bb36566ea03cd1230e6a

- name: Build Protobuf Generator Tool
run: |
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

## [Unreleased]
### Added
- Preprocessing - Support x-protobuf-name overrides existing property and parameter name ([#306](https://github.com/opensearch-project/opensearch-protobufs/pull/306))

### Changed

Expand Down
40 changes: 40 additions & 0 deletions tools/proto-convert/src/SchemaModifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export class SchemaModifier {
this.handleAdditionalPropertiesUndefined(schema)
this.convertNullTypeToNullValue(schema)
this.collapseOrMergeOneOfArray(schema)
this.removeArrayOfMapWrapper(schema)
},
onSchema: (schema, schemaName) => {
if (!schema || isReferenceObject(schema)) return;
Expand All @@ -32,6 +33,7 @@ export class SchemaModifier {
this.handleOneOfConst(schema, schemaName)
this.collapseOrMergeOneOfArray(schema)
this.collapseOneOfObjectPropContainsTitleSchema(schema)
this.removeArrayOfMapWrapper(schema)
},
});
const visit = new Set();
Expand Down Expand Up @@ -435,4 +437,42 @@ export class SchemaModifier {

this.logger.info(`Converted additionalProperties to named property '${propertyName}' with type: object`);
}

/**
* Removes the array wrapper if the schema is an array of maps (additionalProperties).
* Converts array of objects with only additionalProperties into just the additionalProperties schema.
*
* Example:
* Input:
* {
* type: "array",
* items: {
* type: "object",
* additionalProperties: {
* $ref: "#/components/schemas/Value"
* }
* }
* }
*
* Output:
* {
* type: "object",
* additionalProperties: {
* $ref: "#/components/schemas/Value"
* }
* }
**/
removeArrayOfMapWrapper(schema: OpenAPIV3.SchemaObject): void {
if (schema.type === 'array' && schema.items && typeof schema.items === 'object' && !('$ref' in schema.items)) {
const items = schema.items as OpenAPIV3.SchemaObject;

if (items.type === 'object' && items.additionalProperties && !items.properties) {
(schema as any).type = 'object';
schema.additionalProperties = items.additionalProperties;
delete (schema as any).items;

this.logger.info(`Removed array wrapper from array of maps schema`);
}
}
}
}
107 changes: 59 additions & 48 deletions tools/proto-convert/src/VendorExtensionProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OpenAPIV3 } from 'openapi-types';
import { traverse } from './utils/OpenApiTraverser';
import { resolveRef } from './utils/helper';
import { resolveRef, deleteMatchingKeys } from './utils/helper';
import Logger from './utils/logger';

/**
Expand All @@ -10,6 +10,7 @@ import Logger from './utils/logger';
export class VendorExtensionProcessor {
private static readonly PROTOBUF_EXCLUDED_EXTENSION = 'x-protobuf-excluded';
private static readonly PROTOBUF_TYPE_EXTENSION = 'x-protobuf-type';
private static readonly PROTOBUF_NAME_EXTENSION = 'x-protobuf-name';

private static readonly PROTOBUF_TYPE_MAPPING: Record<string, { type: string; format?: string }> = {
'int32': { type: 'integer', format: 'int32' },
Expand All @@ -30,23 +31,26 @@ export class VendorExtensionProcessor {

/**
* Process the spec by pruning anything marked with x-protobuf-excluded
* Direct path-level handling + traverse for schemas only
* and applying vendor extensions (x-protobuf-name, x-protobuf-type)
*/
public process(): OpenAPIV3.Document {
deleteMatchingKeys(this.root, (item: any) => this.hasProtobufExcluded(item));

this.removeProtobufExcludedFromPaths();
traverse(this.root, {
onParameter: (param: any, name: string) => {
this.applyNameOverrideToParameter(param);
},
onSchema: (schema: any, name: string) => {
this.removeProtobufExcludedProperties(schema);
this.applyTypeOverride(schema);
this.applyNameOverride(schema);
},
onResponseSchema: (schema: any, name: string) => {
this.removeProtobufExcludedProperties(schema);
this.applyTypeOverride(schema);
this.applyNameOverride(schema);
},
onRequestSchema: (schema: any, name: string) => {
this.removeProtobufExcludedProperties(schema);
this.applyTypeOverride(schema);
this.applyNameOverride(schema);
},
onSchemaProperty: (schema: any, name: string) => {
this.applyTypeOverride(schema);
Expand All @@ -73,55 +77,61 @@ export class VendorExtensionProcessor {
}

/**
* Remove x-protobuf-excluded items from path-level elements directly
* Apply name override to a parameter if it has x-protobuf-name
*/
private removeProtobufExcludedFromPaths(): void {
if (!this.root.paths) return;

for (const pathKey in this.root.paths) {
const pathItem = this.root.paths[pathKey];
if (!pathItem || typeof pathItem !== 'object' || '$ref' in pathItem) continue;

// Handle operations
for (const method in pathItem) {
if (method === 'parameters' || method === '$ref' || method === 'summary' ||
method === 'description' || method === 'servers') continue;

const operation = (pathItem as any)[method];
if (!operation || typeof operation !== 'object') continue;

// Remove parameters with x-protobuf-excluded
if (Array.isArray(operation.parameters)) {
const originalLength = operation.parameters.length;
operation.parameters = operation.parameters.filter((p: any) => !this.hasProtobufExcluded(p));
const removedCount = originalLength - operation.parameters.length;
if (removedCount > 0) {
this.logger.info(`Removed ${removedCount} parameter(s) from ${method.toUpperCase()} ${pathKey} (${VendorExtensionProcessor.PROTOBUF_EXCLUDED_EXTENSION})`);
}
}
private applyNameOverrideToParameter(param: OpenAPIV3.ParameterObject): void {
if (!param || typeof param !== 'object' || !(VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION in param)) return;

const newName = param[VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION];
if (typeof newName === 'string' && param.name && newName !== param.name) {
const oldName = param.name;
param.name = newName;
delete param[VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION];
this.logger.info(`Renamed parameter '${oldName}' -> '${newName}' (${VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION})`);
}
}


// Remove responses with x-protobuf-excluded
if (operation.responses) {
for (const status in operation.responses) {
if (this.hasProtobufExcluded(operation.responses[status])) {
delete operation.responses[status];
this.logger.info(`Removed response ${status} from ${method.toUpperCase()} ${pathKey} (${VendorExtensionProcessor.PROTOBUF_EXCLUDED_EXTENSION})`);
}
/**
* Apply name override to schema properties and composed schemas (oneOf, anyOf, allOf)
* - For properties: renames property keys
* - For composed schemas: sets title field for sub-schemas
*/
private applyNameOverride(schema: any): void {
if (!schema || typeof schema !== 'object') return;

// Rename properties within schema.properties collection
if (schema?.properties) {
for (const prop in schema.properties) {
const propSchema = schema.properties[prop];
if (propSchema && typeof propSchema === 'object' && VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION in propSchema) {
const newName = propSchema[VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION];
if (typeof newName === 'string' && newName !== prop) {
schema.properties[newName] = schema.properties[prop];
delete schema.properties[prop];
delete schema.properties[newName][VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION];

this.logger.info(`Renamed property '${prop}' -> '${newName}' (${VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION})`);
}
}
}
}
}

private removeProtobufExcludedProperties(schema: OpenAPIV3.SchemaObject): void {
if (!schema?.properties) return;

for (const prop in schema.properties) {
const propSchema = schema.properties[prop];
if (propSchema && typeof propSchema === 'object') {
if (this.hasProtobufExcluded(propSchema)) {
delete schema.properties[prop];
this.logger.info(`Removed schema property ${prop} (${VendorExtensionProcessor.PROTOBUF_EXCLUDED_EXTENSION})`);
// Set title for composed schemas (oneOf, anyOf, allOf)
const composedKeys = ['allOf', 'anyOf', 'oneOf'] as const;
for (const key of composedKeys) {
const subschemas = schema[key];
if (!Array.isArray(subschemas)) continue;

for (const subschema of subschemas) {
if (subschema && typeof subschema === 'object' && VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION in subschema) {
const titleValue = subschema[VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION];
if (typeof titleValue === 'string') {
const oldTitle = subschema.title;
subschema.title = titleValue;
delete subschema[VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION];
this.logger.info(`Set title for ${key} sub-schema: '${oldTitle}' -> '${titleValue}' (${VendorExtensionProcessor.PROTOBUF_NAME_EXTENSION})`);
}
}
}
}
Expand Down Expand Up @@ -170,4 +180,5 @@ export class VendorExtensionProcessor {
this.logger.info(`Applied ${VendorExtensionProcessor.PROTOBUF_TYPE_EXTENSION}: ${protoType} -> type: ${schema.type}${schema.format ? `, format: ${schema.format}` : ''}`);
}
}

}
22 changes: 22 additions & 0 deletions tools/proto-convert/src/utils/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {mkdirSync, writeFileSync, readFileSync} from 'fs'
import {parse, visit, Document} from 'yaml'
import {dirname} from "path";
import {OpenAPIV3} from "openapi-types";
import _ from 'lodash';

export function read_yaml<T = Record<string, any>> (file_path: string, exclude_schema: boolean = false): T {
const doc = parse(readFileSync(file_path, 'utf8'))
Expand Down Expand Up @@ -113,3 +114,24 @@ export function isEmptyObjectSchema(schema: OpenAPIV3.SchemaObject): boolean {
export function isReferenceObject(schema: any): schema is OpenAPIV3.ReferenceObject {
return schema !== null && typeof schema === 'object' && '$ref' in schema;
}

/**
* Recursively delete all items matching the given condition
* This includes removing them from their parent collections and cleaning up empty arrays
*/
export function deleteMatchingKeys(obj: any, condition: (item: any) => boolean): void {
for (const key in obj) {
const item = obj[key];

if (_.isObject(item)) {
if (condition(item)) {
delete obj[key];
} else {
deleteMatchingKeys(item, condition);
if (_.isArray(item)) {
obj[key] = _.compact(item);
}
}
}
}
}