Skip to content

Commit 2ec5f5e

Browse files
committed
Enhance spec-filter to support x-operation-group filtering and merge parameters across operations in same x-operation-group instead of selecting by max parameters
Signed-off-by: xil <fridalu66@gmail.com>
1 parent 14c20da commit 2ec5f5e

9 files changed

Lines changed: 1176 additions & 79 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
44

55
## [Unreleased]
66
### Added
7+
- Enhance spec-filter to support x-operation-group filtering and merge parameters across operations in same x-operation-group instead of selecting by max parameters ([#374](https://github.com/opensearch-project/opensearch-protobufs/pull/374))
78

89
### Changed
910

tools/proto-convert/src/Filter.ts

Lines changed: 102 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ function traverse_and_enqueue(node: any, queue: string[], visited: Set<string>,
4040
export default class Filter {
4141
protected input: Record<string, any>
4242
protected output: Record<string, any>
43-
protected targetPaths: string[]
43+
protected targetPathsMap: Map<string, Set<string> | null> // path -> operation-groups (null means all operations)
4444
protected excludedSchemas: Set<string>
4545
paths: Record<string, Record<string, OpenAPIV3.PathItemObject>> = {} // namespace -> path -> path_item_object
4646

47-
constructor(input: Record<string, any>, targetPaths: string[], excludedSchemas: Set<string> = new Set()) {
47+
constructor(input: Record<string, any>, targetPathsMap: Map<string, Set<string> | null>, excludedSchemas: Set<string> = new Set()) {
4848
this.input = input;
49-
this.targetPaths = targetPaths;
49+
this.targetPathsMap = targetPathsMap;
5050
this.excludedSchemas = excludedSchemas;
5151
if (this.excludedSchemas.size > 0) {
5252
logger.info(`Loaded ${this.excludedSchemas.size} excluded schemas: ${Array.from(this.excludedSchemas).join(', ')}`);
@@ -67,14 +67,32 @@ export default class Filter {
6767

6868
filter(): OpenAPIV3.Document {
6969
this.output.info = this.input.info;
70-
for (const p of this.targetPaths) {
71-
if (this.input.paths[p] === undefined) {
72-
logger.error(`Path not found in spec: ${p}`);
70+
71+
for (const [targetPath, targetGroups] of this.targetPathsMap) {
72+
if (this.input.paths[targetPath] === undefined) {
73+
logger.error(`Path not found in spec: ${targetPath}`);
7374
continue;
7475
}
75-
this.output.paths[p] = this.input.paths[p];
76+
77+
const pathItem = this.input.paths[targetPath];
78+
79+
if (targetGroups === null) {
80+
this.output.paths[targetPath] = pathItem;
81+
} else {
82+
const filteredPathItem: any = {};
83+
for (const method of ['get', 'post', 'put', 'delete', 'head'] as const) {
84+
const operation = pathItem?.[method];
85+
if (operation && operation['x-operation-group'] && targetGroups.has(operation['x-operation-group'])) {
86+
filteredPathItem[method] = operation;
87+
}
88+
}
89+
if (Object.keys(filteredPathItem).length > 0) {
90+
this.output.paths[targetPath] = filteredPathItem;
91+
}
92+
}
7693
}
77-
this.filter_by_max_parameters(this.output.paths as OpenAPIV3.PathsObject);
94+
95+
this.mergeOperationsByGroup(this.output.paths as OpenAPIV3.PathsObject);
7896
const queue: string[] = [];
7997
const visited: Set<string> = new Set();
8098

@@ -101,65 +119,98 @@ export default class Filter {
101119
return this.output as OpenAPIV3.Document;
102120
}
103121

104-
filter_by_max_parameters(paths: OpenAPIV3.PathsObject): void {
122+
/**
123+
* Merges operations with the same x-operation-group into a single operation.
124+
* - Validates that requestBody and responses are identical across paths
125+
* - Merges all parameters from different paths into a union
126+
*/
127+
mergeOperationsByGroup(paths: OpenAPIV3.PathsObject): void {
105128
const new_paths: OpenAPIV3.PathsObject = {};
106-
let operation_map = new Map<string, Array<Record<string, OpenAPIV3.PathItemObject>>>();
129+
130+
// Group operations by x-operation-group
131+
type OperationInfo = {
132+
path: string;
133+
method: string;
134+
operation: OpenAPIV3.OperationObject;
135+
};
136+
const operationsByGroup = new Map<string, OperationInfo[]>();
137+
107138
for (const path in paths) {
108139
const path_item = paths[path];
109140
if (!path_item) continue;
110141
for (const method of Object.keys(path_item) as Array<keyof OpenAPIV3.PathItemObject>) {
111142
const operation = path_item[method];
112143
if (operation != null && typeof operation === 'object' && 'x-operation-group' in operation) {
113144
const group: string = operation['x-operation-group'] as string;
114-
if (!operation_map.get(group)) {
115-
operation_map.set(group, []);
145+
if (!operationsByGroup.has(group)) {
146+
operationsByGroup.set(group, []);
116147
}
117-
const group_map: Record<string, OpenAPIV3.PathItemObject> = {
118-
[path]: {
119-
[method]: operation as OpenAPIV3.OperationObject,
120-
},
121-
};
122-
operation_map.get(group)?.push(group_map);
148+
operationsByGroup.get(group)!.push({
149+
path,
150+
method,
151+
operation: operation as OpenAPIV3.OperationObject
152+
});
123153
}
124154
}
125155
}
126156

157+
for (const [group, operations] of operationsByGroup.entries()) {
158+
if (operations.length === 0) continue;
127159

128-
for (const operations of operation_map.values()) {
129-
let max_parameters = -1;
130-
let max_path_item: OpenAPIV3.PathItemObject | null = null;
131-
let max_path = '';
132-
for (const op of operations) {
133-
for (const [path, path_item] of Object.entries(op)) {
134-
for (const operation of Object.values(path_item)) {
135-
if (operation != null && typeof operation === 'object' && Array.isArray((operation as OpenAPIV3.OperationObject).parameters)) {
136-
const param_count = (operation as OpenAPIV3.OperationObject).parameters?.length??0;
137-
if (param_count > max_parameters) {
138-
max_parameters = param_count;
139-
max_path = path;
140-
max_path_item = path_item;
141-
}
142-
}
143-
}
144-
}
145-
}
146-
if ((max_path_item?.get?.operationId) != null) {
147-
max_path_item.get.operationId = max_path_item.get.operationId.replace(/\.\d+$/, '');
148-
new_paths[max_path] = { ...new_paths[max_path], get: max_path_item.get };
149-
} else if ((max_path_item?.post?.operationId != null)) {
150-
max_path_item.post.operationId = max_path_item.post.operationId.replace(/\.\d+$/, '');
151-
new_paths[max_path] = { ...new_paths[max_path], post: max_path_item.post };
152-
} else if ((max_path_item?.put?.operationId != null)) {
153-
max_path_item.put.operationId = max_path_item.put.operationId.replace(/\.\d+$/, '');
154-
new_paths[max_path] = { ...new_paths[max_path], put: max_path_item.put };
155-
} else if ((max_path_item?.delete?.operationId != null)) {
156-
max_path_item.delete.operationId = max_path_item.delete.operationId.replace(/\.\d+$/, '');
157-
new_paths[max_path] = { ...new_paths[max_path], delete: max_path_item.delete };
158-
} else if ((max_path_item?.head?.operationId != null)) {
159-
max_path_item.head.operationId = max_path_item.head.operationId.replace(/\.\d+$/, '');
160-
new_paths[max_path] = { ...new_paths[max_path], head: max_path_item.head };
160+
// 1. Validate requestBody and responses are consistent before merging
161+
this.validateConsistency(group, operations);
162+
163+
// 2. Merge parameters from all operations
164+
const mergedParams = this.mergeParameters(operations);
165+
166+
// 3. Create merged operation
167+
const firstOp = operations[0];
168+
const mergedOperation: OpenAPIV3.OperationObject = {
169+
...firstOp.operation,
170+
operationId: firstOp.operation.operationId?.replace(/\.\d+$/, ''),
171+
parameters: mergedParams
172+
};
173+
174+
if (!new_paths[firstOp.path]) {
175+
new_paths[firstOp.path] = {};
161176
}
177+
(new_paths[firstOp.path] as any)[firstOp.method] = mergedOperation;
162178
}
179+
163180
this.output.paths = new_paths;
164181
}
182+
183+
private validateConsistency(group: string, operations: { path: string; operation: OpenAPIV3.OperationObject }[]): void {
184+
// Check requestBody consistency
185+
const requestBodies = operations
186+
.map(op => JSON.stringify(op.operation.requestBody))
187+
.filter(rb => rb !== 'undefined');
188+
const uniqueRequestBodies = new Set(requestBodies);
189+
if (uniqueRequestBodies.size > 1) {
190+
throw new Error(`Operation group '${group}' has inconsistent requestBody across paths: ${operations.map(op => op.path).join(', ')}`);
191+
}
192+
193+
// Check responses consistency
194+
const responses = operations.map(op => JSON.stringify(op.operation.responses));
195+
const uniqueResponses = new Set(responses);
196+
if (uniqueResponses.size > 1) {
197+
throw new Error(`Operation group '${group}' has inconsistent responses across paths: ${operations.map(op => op.path).join(', ')}`);
198+
}
199+
}
200+
201+
private mergeParameters(operations: { operation: OpenAPIV3.OperationObject }[]): (OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject)[] {
202+
const allParams = new Map<string, OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject>();
203+
for (const op of operations) {
204+
const params = op.operation.parameters ?? [];
205+
for (const param of params) {
206+
const key = '$ref' in param
207+
? param.$ref
208+
: `${(param as OpenAPIV3.ParameterObject).name}:${(param as OpenAPIV3.ParameterObject).in}`;
209+
if (!allParams.has(key)) {
210+
allParams.set(key, param);
211+
}
212+
}
213+
}
214+
return Array.from(allParams.values());
215+
}
165216
}

tools/proto-convert/src/PreProcessing.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { Command, Option } from '@commander-js/extra-typings';
2-
import { read_yaml, write_yaml } from './utils/helper';
2+
import { read_yaml, write_yaml, parsePathsConfig } from './utils/helper';
3+
4+
// Path config type: path -> { x-operation-group: string[] }
5+
type PathConfig = Record<string, { 'x-operation-group'?: string[] } | null>;
36
import Filter from './Filter';
47
import { Sanitizer } from './Sanitizer';
58
import logger from './utils/logger';
@@ -10,21 +13,17 @@ import {GlobalParameterConsolidator} from "./GlobalParamWrapper";
1013
import {OpenSearchVersionExtractor} from "./OpenSearchVersionExtractor";
1114

1215
// Load config from spec-filter.yaml
13-
const config = read_yaml<{ paths?: string[]; excluded_schemas?: string[] }>(
16+
const config = read_yaml<{ paths?: PathConfig; excluded_schemas?: string[] }>(
1417
path.join(__dirname, 'config', 'spec-filter.yaml')
1518
);
16-
const target_paths = config.paths ?? ['/_search'];
19+
20+
const target_paths_map = parsePathsConfig(config.paths);
1721
const excluded_schemas = new Set(config.excluded_schemas ?? []);
1822

1923
const command = new Command()
2024
.description('Preprocess an OpenAPI spec by filtering for specific paths and then sanitizing it.')
2125
.addOption(new Option('-i, --input <path>', 'input YAML file').default((path.resolve(__dirname, '../../../opensearch-openapi.yaml'))))
2226
.addOption(new Option('-o, --output <path>', 'output YAML file').default((path.resolve(__dirname, '../../../build/processed-opensearch-openapi.yaml'))))
23-
.addOption(
24-
new Option('-p, --filtered_path <paths>', 'the paths to keep (comma-separated, e.g., /_search,)')
25-
.argParser((val: string) => val.split(',').map(s => s.trim()))
26-
.default(target_paths)
27-
)
2827
.addOption(new Option('--verbose', 'show merge details').default(false))
2928
.addOption(new Option('--opensearch-version <version>', 'current OpenSearch version for deprecation removal').default('3.4'))
3029
.allowExcessArguments(false)
@@ -34,17 +33,17 @@ const command = new Command()
3433
type PreprocessingOpts = {
3534
input: string;
3635
output: string;
37-
filtered_path: string[];
3836
verbose: boolean;
3937
opensearchVersion: string;
4038
};
4139

4240
const opts = command.opts() as PreprocessingOpts;
4341

4442
try {
45-
logger.info(`PreProcessing ${opts.filtered_path.join(', ')} into ${opts.output} ...`)
43+
const pathsList = Array.from(target_paths_map.keys());
44+
logger.info(`PreProcessing ${pathsList.join(', ')} into ${opts.output} ...`)
4645
const original_spec = read_yaml(opts.input)
47-
const filtered_spec = new Filter(original_spec, opts.filtered_path, excluded_schemas).filter();
46+
const filtered_spec = new Filter(original_spec, target_paths_map, excluded_schemas).filter();
4847
const version_processed_spec = new OpenSearchVersionExtractor(filtered_spec).process(opts.opensearchVersion);
4948
const sanitized_spec = new Sanitizer(version_processed_spec).sanitize();
5049
const consolidated_spec = new GlobalParameterConsolidator(sanitized_spec).consolidate();

tools/proto-convert/src/config/protobuf-schema-template/partial_header.mustache

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
syntax = "proto3";
55
package org.opensearch.protobufs;
66

7-
import "google/protobuf/struct.proto";
8-
97
option go_package = "github.com/opensearch-project/opensearch-protobufs/go/opensearchpb";
108
option java_multiple_files = true;
119
option java_outer_classname = "CommonProto";
12-
option java_package = "org.opensearch.protobufs";
10+
option java_package = "org.opensearch.protobufs";

tools/proto-convert/src/config/spec-filter.yaml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
# Target API paths to include in proto generation
2+
# Format:
3+
# path:
4+
# x-operation-group: # optional, list of operation groups to include
5+
# - group1
6+
# - group2
7+
# If x-operation-group is not specified, all operations on that path are included
28
paths:
3-
- /{index}/_bulk
4-
- /{index}/_search
9+
/{index}/_bulk:
10+
x-operation-group:
11+
- bulk
12+
/{index}/_search:
13+
x-operation-group:
14+
- search
515

616
# Schemas to exclude from proto generation
717
# These schemas and their nested dependencies will not be included

tools/proto-convert/src/utils/helper.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ export function remove_unused(spec: OpenAPIV3.Document): void {
198198
!_.includes(remaining, obj.additionalProperties.items.$ref)) {
199199
return true;
200200
}
201+
// Case 4: Object has empty properties (properties: {})
202+
if (obj.properties && _.isObject(obj.properties) && _.isEmpty(obj.properties) && obj.type !== 'object') {
203+
return true;
204+
}
201205
return false;
202206
});
203207
}
@@ -212,3 +216,21 @@ export function is_simple_ref(schema: any): boolean {
212216
const keys = Object.keys(schema);
213217
return keys.length === 1 && '$ref' in schema;
214218
}
219+
220+
/**
221+
* Convert paths config to Map<path, Set<operation-groups>>
222+
* @param paths - The path configuration from spec-filter.yaml
223+
* @returns Map where key is path and value is Set of operation groups (null means all operations)
224+
*/
225+
export function parsePathsConfig(paths: Record<string, { 'x-operation-group'?: string[] } | null> | undefined): Map<string, Set<string> | null> {
226+
const result = new Map<string, Set<string> | null>();
227+
if (!paths) {
228+
result.set('/_search', null);
229+
return result;
230+
}
231+
for (const [p, config] of Object.entries(paths)) {
232+
const groups = config?.['x-operation-group'];
233+
result.set(p, groups && groups.length > 0 ? new Set(groups) : null);
234+
}
235+
return result;
236+
}

0 commit comments

Comments
 (0)