Skip to content

Commit 6344ff7

Browse files
github-actions[bot]Junwei Dai
andcommitted
Add ApiSpecFetcher for Fetching and Comparing API Specifications (#900)
* Added ApiSpecFetcher with test Signed-off-by: Junwei Dai <junweid@amazon.com> * remove duplication license Signed-off-by: Junwei Dai <junweid@amazon.com> * Add more test to pass test coverage check Signed-off-by: Junwei Dai <junweid@amazon.com> * new commit address all comments Signed-off-by: Junwei Dai <junweid@amazon.com> * new commit address all comments Signed-off-by: Junwei Dai <junweid@amazon.com> * Addressed all comments Signed-off-by: Junwei Dai <junweid@amazon.com> --------- Signed-off-by: Junwei Dai <junweid@amazon.com> Co-authored-by: Junwei Dai <junweid@amazon.com> (cherry picked from commit 57b8b59) Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 0cde785 commit 6344ff7

8 files changed

Lines changed: 379 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
1818

1919
## [Unreleased 2.x](https://github.com/opensearch-project/flow-framework/compare/2.17...2.x)
2020
### Features
21+
- Add ApiSpecFetcher for Fetching and Comparing API Specifications ([#651](https://github.com/opensearch-project/flow-framework/issues/651))
22+
2123
### Enhancements
2224
### Bug Fixes
2325
### Infrastructure

build.gradle

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ buildscript {
2424
opensearch_no_snapshot = opensearch_build.replace("-SNAPSHOT","")
2525
System.setProperty('tests.security.manager', 'false')
2626
common_utils_version = System.getProperty("common_utils.version", opensearch_build)
27-
27+
swaggerCoreVersion = "2.2.23"
2828
bwcVersionShort = "2.12.0"
2929
bwcVersion = bwcVersionShort + ".0"
3030
bwcOpenSearchFFDownload = 'https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/' + bwcVersionShort + '/latest/linux/x64/tar/builds/' +
@@ -34,6 +34,10 @@ buildscript {
3434
bwcFlowFrameworkPath = bwcFilePath + "flowframework/"
3535

3636
isSameMajorVersion = opensearch_version.split("\\.")[0] == bwcVersionShort.split("\\.")[0]
37+
swaggerVersion = "2.1.22"
38+
jacksonVersion = "2.18.0"
39+
swaggerCoreVersion = "2.2.23"
40+
3741
}
3842

3943
repositories {
@@ -178,6 +182,15 @@ dependencies {
178182
implementation "org.glassfish:jakarta.json:2.0.1"
179183
implementation "org.eclipse:yasson:3.0.4"
180184
implementation "com.google.code.gson:gson:2.11.0"
185+
// Swagger-Parser dependencies for API consistency tests
186+
implementation "io.swagger.core.v3:swagger-models:${swaggerCoreVersion}"
187+
implementation "io.swagger.core.v3:swagger-core:${swaggerCoreVersion}"
188+
implementation "io.swagger.parser.v3:swagger-parser-core:${swaggerVersion}"
189+
implementation "io.swagger.parser.v3:swagger-parser:${swaggerVersion}"
190+
implementation "io.swagger.parser.v3:swagger-parser-v3:${swaggerVersion}"
191+
implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"
192+
implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}"
193+
implementation "com.fasterxml.jackson.core:jackson-annotations:${jacksonVersion}"
181194

182195
// ZipArchive dependencies used for integration tests
183196
zipArchive group: 'org.opensearch.plugin', name:'opensearch-ml-plugin', version: "${opensearch_build}"

src/main/java/org/opensearch/flowframework/common/CommonValue.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,9 @@ private CommonValue() {}
233233
public static final String CREATE_INGEST_PIPELINE_MODEL_ID = "create_ingest_pipeline.model_id";
234234
/** The field name for reindex source index substitution */
235235
public static final String REINDEX_SOURCE_INDEX = "reindex.source_index";
236+
237+
/**URI for the YAML file of the ML Commons API specification.*/
238+
public static final String ML_COMMONS_API_SPEC_YAML_URI =
239+
"https://raw.githubusercontent.com/opensearch-project/opensearch-api-specification/refs/heads/main/spec/namespaces/ml.yaml";
240+
236241
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
package org.opensearch.flowframework.exception;
10+
11+
import org.opensearch.OpenSearchException;
12+
13+
import java.util.List;
14+
15+
/**
16+
* Custom exception to be thrown when an error occurs during the parsing of an API specification.
17+
*/
18+
public class ApiSpecParseException extends OpenSearchException {
19+
20+
/**
21+
* Constructor with message.
22+
*
23+
* @param message The detail message.
24+
*/
25+
public ApiSpecParseException(String message) {
26+
super(message);
27+
}
28+
29+
/**
30+
* Constructor with message and cause.
31+
*
32+
* @param message The detail message.
33+
* @param cause The cause of the exception.
34+
*/
35+
public ApiSpecParseException(String message, Throwable cause) {
36+
super(message, cause);
37+
}
38+
39+
/**
40+
* Constructor with message and list of detailed errors.
41+
*
42+
* @param message The detail message.
43+
* @param details The list of errors encountered during the parsing process.
44+
*/
45+
public ApiSpecParseException(String message, List<String> details) {
46+
super(message + ": " + String.join(", ", details));
47+
}
48+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
package org.opensearch.flowframework.util;
10+
11+
import org.apache.logging.log4j.LogManager;
12+
import org.apache.logging.log4j.Logger;
13+
import org.opensearch.common.xcontent.XContentType;
14+
import org.opensearch.flowframework.exception.ApiSpecParseException;
15+
import org.opensearch.rest.RestRequest;
16+
17+
import java.util.HashSet;
18+
import java.util.List;
19+
20+
import io.swagger.v3.oas.models.OpenAPI;
21+
import io.swagger.v3.oas.models.Operation;
22+
import io.swagger.v3.oas.models.PathItem;
23+
import io.swagger.v3.oas.models.media.Content;
24+
import io.swagger.v3.oas.models.media.MediaType;
25+
import io.swagger.v3.oas.models.media.Schema;
26+
import io.swagger.v3.oas.models.parameters.RequestBody;
27+
import io.swagger.v3.parser.OpenAPIV3Parser;
28+
import io.swagger.v3.parser.core.models.ParseOptions;
29+
import io.swagger.v3.parser.core.models.SwaggerParseResult;
30+
31+
/**
32+
* Utility class for fetching and parsing OpenAPI specifications.
33+
*/
34+
public class ApiSpecFetcher {
35+
private static final Logger logger = LogManager.getLogger(ApiSpecFetcher.class);
36+
private static final ParseOptions PARSE_OPTIONS = new ParseOptions();
37+
private static final OpenAPIV3Parser OPENAPI_PARSER = new OpenAPIV3Parser();
38+
39+
static {
40+
PARSE_OPTIONS.setResolve(true);
41+
PARSE_OPTIONS.setResolveFully(true);
42+
}
43+
44+
/**
45+
* Parses the OpenAPI specification directly from the URI.
46+
*
47+
* @param apiSpecUri URI to the API specification (can be file path or web URI).
48+
* @return Parsed OpenAPI object.
49+
* @throws ApiSpecParseException If parsing fails.
50+
*/
51+
public static OpenAPI fetchApiSpec(String apiSpecUri) {
52+
logger.info("Parsing API spec from URI: {}", apiSpecUri);
53+
SwaggerParseResult result = OPENAPI_PARSER.readLocation(apiSpecUri, null, PARSE_OPTIONS);
54+
OpenAPI openApi = result.getOpenAPI();
55+
56+
if (openApi == null) {
57+
throw new ApiSpecParseException("Unable to parse spec from URI: " + apiSpecUri, result.getMessages());
58+
}
59+
60+
return openApi;
61+
}
62+
63+
/**
64+
* Compares the required fields in the API spec with the required enum parameters.
65+
*
66+
* @param requiredEnumParams List of required parameters from the enum.
67+
* @param apiSpecUri URI of the API spec to fetch and compare.
68+
* @param path The API path to check.
69+
* @param method The HTTP method (POST, GET, etc.).
70+
* @return boolean indicating if the required fields match.
71+
*/
72+
public static boolean compareRequiredFields(List<String> requiredEnumParams, String apiSpecUri, String path, RestRequest.Method method)
73+
throws IllegalArgumentException, ApiSpecParseException {
74+
OpenAPI openAPI = fetchApiSpec(apiSpecUri);
75+
76+
PathItem pathItem = openAPI.getPaths().get(path);
77+
Content content = getContent(method, pathItem);
78+
MediaType mediaType = content.get(XContentType.JSON.mediaTypeWithoutParameters());
79+
if (mediaType != null) {
80+
Schema<?> schema = mediaType.getSchema();
81+
82+
List<String> requiredApiParams = schema.getRequired();
83+
if (requiredApiParams != null && !requiredApiParams.isEmpty()) {
84+
return new HashSet<>(requiredEnumParams).equals(new HashSet<>(requiredApiParams));
85+
}
86+
}
87+
return false;
88+
}
89+
90+
private static Content getContent(RestRequest.Method method, PathItem pathItem) throws IllegalArgumentException, ApiSpecParseException {
91+
Operation operation;
92+
switch (method) {
93+
case POST:
94+
operation = pathItem.getPost();
95+
break;
96+
case GET:
97+
operation = pathItem.getGet();
98+
break;
99+
case PUT:
100+
operation = pathItem.getPut();
101+
break;
102+
case DELETE:
103+
operation = pathItem.getDelete();
104+
break;
105+
default:
106+
throw new IllegalArgumentException("Unsupported HTTP method: " + method);
107+
}
108+
109+
if (operation == null) {
110+
throw new IllegalArgumentException("No operation found for the specified method: " + method);
111+
}
112+
113+
RequestBody requestBody = operation.getRequestBody();
114+
if (requestBody == null) {
115+
throw new ApiSpecParseException("No requestBody defined for this operation.");
116+
}
117+
118+
return requestBody.getContent();
119+
}
120+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
package org.opensearch.flowframework.exception;
10+
11+
import org.opensearch.OpenSearchException;
12+
import org.opensearch.test.OpenSearchTestCase;
13+
14+
import java.util.Arrays;
15+
import java.util.List;
16+
17+
public class ApiSpecParseExceptionTests extends OpenSearchTestCase {
18+
19+
public void testApiSpecParseException() {
20+
ApiSpecParseException exception = new ApiSpecParseException("API spec parsing failed");
21+
assertTrue(exception instanceof OpenSearchException);
22+
assertEquals("API spec parsing failed", exception.getMessage());
23+
}
24+
25+
public void testApiSpecParseExceptionWithCause() {
26+
Throwable cause = new RuntimeException("Underlying issue");
27+
ApiSpecParseException exception = new ApiSpecParseException("API spec parsing failed", cause);
28+
assertTrue(exception instanceof OpenSearchException);
29+
assertEquals("API spec parsing failed", exception.getMessage());
30+
assertEquals(cause, exception.getCause());
31+
}
32+
33+
public void testApiSpecParseExceptionWithDetailedErrors() {
34+
String message = "API spec parsing failed";
35+
List<String> details = Arrays.asList("Missing required field", "Invalid type");
36+
ApiSpecParseException exception = new ApiSpecParseException(message, details);
37+
assertTrue(exception instanceof OpenSearchException);
38+
String expectedMessage = "API spec parsing failed: Missing required field, Invalid type";
39+
assertEquals(expectedMessage, exception.getMessage());
40+
}
41+
42+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* The OpenSearch Contributors require contributions made to
6+
* this file be licensed under the Apache-2.0 license or a
7+
* compatible open source license.
8+
*/
9+
package org.opensearch.flowframework.util;
10+
11+
import org.opensearch.flowframework.exception.ApiSpecParseException;
12+
import org.opensearch.rest.RestRequest;
13+
import org.opensearch.test.OpenSearchTestCase;
14+
import org.junit.Before;
15+
16+
import java.util.Arrays;
17+
import java.util.List;
18+
19+
import io.swagger.v3.oas.models.OpenAPI;
20+
21+
import static org.opensearch.flowframework.common.CommonValue.ML_COMMONS_API_SPEC_YAML_URI;
22+
import static org.opensearch.rest.RestRequest.Method.DELETE;
23+
import static org.opensearch.rest.RestRequest.Method.PATCH;
24+
import static org.opensearch.rest.RestRequest.Method.POST;
25+
import static org.opensearch.rest.RestRequest.Method.PUT;
26+
27+
public class ApiSpecFetcherTests extends OpenSearchTestCase {
28+
29+
private ApiSpecFetcher apiSpecFetcher;
30+
31+
@Before
32+
public void setUp() throws Exception {
33+
super.setUp();
34+
}
35+
36+
public void testFetchApiSpecSuccess() throws Exception {
37+
38+
OpenAPI result = ApiSpecFetcher.fetchApiSpec(ML_COMMONS_API_SPEC_YAML_URI);
39+
40+
assertNotNull("The fetched OpenAPI spec should not be null", result);
41+
}
42+
43+
public void testFetchApiSpecThrowsException() throws Exception {
44+
String invalidUri = "http://invalid-url.com/fail.yaml";
45+
46+
ApiSpecParseException exception = expectThrows(ApiSpecParseException.class, () -> { ApiSpecFetcher.fetchApiSpec(invalidUri); });
47+
48+
assertNotNull("Exception should be thrown for invalid URI", exception);
49+
assertTrue(exception.getMessage().contains("Unable to parse spec"));
50+
}
51+
52+
public void testCompareRequiredFieldsSuccess() throws Exception {
53+
54+
String path = "/_plugins/_ml/agents/_register";
55+
RestRequest.Method method = POST;
56+
57+
// Assuming REGISTER_AGENT step in the enum has these required fields
58+
List<String> expectedRequiredParams = Arrays.asList("name", "type");
59+
60+
boolean comparisonResult = ApiSpecFetcher.compareRequiredFields(expectedRequiredParams, ML_COMMONS_API_SPEC_YAML_URI, path, method);
61+
62+
assertTrue("The required fields should match between API spec and enum", comparisonResult);
63+
}
64+
65+
public void testCompareRequiredFieldsFailure() throws Exception {
66+
67+
String path = "/_plugins/_ml/agents/_register";
68+
RestRequest.Method method = POST;
69+
70+
List<String> wrongRequiredParams = Arrays.asList("nonexistent_param");
71+
72+
boolean comparisonResult = ApiSpecFetcher.compareRequiredFields(wrongRequiredParams, ML_COMMONS_API_SPEC_YAML_URI, path, method);
73+
74+
assertFalse("The required fields should not match for incorrect input", comparisonResult);
75+
}
76+
77+
public void testCompareRequiredFieldsThrowsException() throws Exception {
78+
String invalidUri = "http://invalid-url.com/fail.yaml";
79+
String path = "/_plugins/_ml/agents/_register";
80+
RestRequest.Method method = PUT;
81+
82+
Exception exception = expectThrows(
83+
Exception.class,
84+
() -> { ApiSpecFetcher.compareRequiredFields(List.of(), invalidUri, path, method); }
85+
);
86+
87+
assertNotNull("An exception should be thrown for an invalid API spec Uri", exception);
88+
assertTrue(exception.getMessage().contains("Unable to parse spec"));
89+
}
90+
91+
public void testUnsupportedMethodException() throws IllegalArgumentException {
92+
Exception exception = expectThrows(Exception.class, () -> {
93+
ApiSpecFetcher.compareRequiredFields(
94+
List.of("name", "type"),
95+
ML_COMMONS_API_SPEC_YAML_URI,
96+
"/_plugins/_ml/agents/_register",
97+
PATCH
98+
);
99+
});
100+
101+
assertEquals("Unsupported HTTP method: PATCH", exception.getMessage());
102+
}
103+
104+
public void testNoOperationFoundException() throws Exception {
105+
Exception exception = expectThrows(IllegalArgumentException.class, () -> {
106+
ApiSpecFetcher.compareRequiredFields(
107+
List.of("name", "type"),
108+
ML_COMMONS_API_SPEC_YAML_URI,
109+
"/_plugins/_ml/agents/_register",
110+
DELETE
111+
);
112+
});
113+
114+
assertEquals("No operation found for the specified method: DELETE", exception.getMessage());
115+
}
116+
117+
public void testNoRequestBodyDefinedException() throws ApiSpecParseException {
118+
Exception exception = expectThrows(ApiSpecParseException.class, () -> {
119+
ApiSpecFetcher.compareRequiredFields(
120+
List.of("name", "type"),
121+
ML_COMMONS_API_SPEC_YAML_URI,
122+
"/_plugins/_ml/model_groups/{model_group_id}",
123+
RestRequest.Method.GET
124+
);
125+
});
126+
127+
assertEquals("No requestBody defined for this operation.", exception.getMessage());
128+
}
129+
130+
}

0 commit comments

Comments
 (0)