Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ApiSpecFetcher for Fetching and Comparing API Specifications #900

Merged
merged 7 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)

## [Unreleased 2.x](https://github.com/opensearch-project/flow-framework/compare/2.17...2.x)
### Features
- Add ApiSpecFetcher for Fetching and Comparing API Specifications ([#651](https://github.com/opensearch-project/flow-framework/issues/651))
junweid62 marked this conversation as resolved.
Show resolved Hide resolved

### Enhancements
### Bug Fixes
### Infrastructure
Expand Down
15 changes: 14 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ buildscript {
opensearch_no_snapshot = opensearch_build.replace("-SNAPSHOT","")
System.setProperty('tests.security.manager', 'false')
common_utils_version = System.getProperty("common_utils.version", opensearch_build)

swaggerCoreVersion = "2.2.23"
bwcVersionShort = "2.12.0"
bwcVersion = bwcVersionShort + ".0"
bwcOpenSearchFFDownload = 'https://ci.opensearch.org/ci/dbc/distribution-build-opensearch/' + bwcVersionShort + '/latest/linux/x64/tar/builds/' +
Expand All @@ -34,6 +34,10 @@ buildscript {
bwcFlowFrameworkPath = bwcFilePath + "flowframework/"

isSameMajorVersion = opensearch_version.split("\\.")[0] == bwcVersionShort.split("\\.")[0]
swaggerVersion = "2.1.22"
jacksonVersion = "2.18.0"
swaggerCoreVersion = "2.2.23"

}

repositories {
Expand Down Expand Up @@ -179,6 +183,15 @@ dependencies {
implementation "org.glassfish:jakarta.json:2.0.1"
implementation "org.eclipse:yasson:3.0.4"
implementation "com.google.code.gson:gson:2.11.0"
// Swagger-Parser dependencies for API consistency tests
implementation "io.swagger.core.v3:swagger-models:${swaggerCoreVersion}"
implementation "io.swagger.core.v3:swagger-core:${swaggerCoreVersion}"
implementation "io.swagger.parser.v3:swagger-parser-core:${swaggerVersion}"
implementation "io.swagger.parser.v3:swagger-parser:${swaggerVersion}"
implementation "io.swagger.parser.v3:swagger-parser-v3:${swaggerVersion}"
implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}"
implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${jacksonVersion}"
implementation "com.fasterxml.jackson.core:jackson-annotations:${jacksonVersion}"
owaiskazi19 marked this conversation as resolved.
Show resolved Hide resolved

// ZipArchive dependencies used for integration tests
zipArchive group: 'org.opensearch.plugin', name:'opensearch-ml-plugin', version: "${opensearch_build}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,9 @@ private CommonValue() {}
public static final String CREATE_INGEST_PIPELINE_MODEL_ID = "create_ingest_pipeline.model_id";
/** The field name for reindex source index substitution */
public static final String REINDEX_SOURCE_INDEX = "reindex.source_index";

/**URI for the YAML file of the ML Commons API specification.*/
public static final String ML_COMMONS_API_SPEC_YAML_URI =
"https://raw.githubusercontent.com/opensearch-project/opensearch-api-specification/refs/heads/main/spec/namespaces/ml.yaml";

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/
package org.opensearch.flowframework.exception;

import org.opensearch.OpenSearchException;

import java.util.List;

/**
* Custom exception to be thrown when an error occurs during the parsing of an API specification.
*/
public class ApiSpecParseException extends OpenSearchException {
dbwiddis marked this conversation as resolved.
Show resolved Hide resolved

/**
* Constructor with message.
*
* @param message The detail message.
*/
public ApiSpecParseException(String message) {
super(message);
}

/**
* Constructor with message and cause.
*
* @param message The detail message.
* @param cause The cause of the exception.
*/
public ApiSpecParseException(String message, Throwable cause) {
super(message, cause);
}

/**
* Constructor with message and list of detailed errors.
*
* @param message The detail message.
* @param details The list of errors encountered during the parsing process.
*/
public ApiSpecParseException(String message, List<String> details) {
super(message + ": " + String.join(", ", details));
}
}
120 changes: 120 additions & 0 deletions src/main/java/org/opensearch/flowframework/util/ApiSpecFetcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/
package org.opensearch.flowframework.util;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.opensearch.common.xcontent.XContentType;
import org.opensearch.flowframework.exception.ApiSpecParseException;
import org.opensearch.rest.RestRequest;

import java.util.HashSet;
import java.util.List;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.media.Content;
import io.swagger.v3.oas.models.media.MediaType;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.parameters.RequestBody;
import io.swagger.v3.parser.OpenAPIV3Parser;
import io.swagger.v3.parser.core.models.ParseOptions;
import io.swagger.v3.parser.core.models.SwaggerParseResult;

/**
* Utility class for fetching and parsing OpenAPI specifications.
*/
public class ApiSpecFetcher {

Check warning on line 34 in src/main/java/org/opensearch/flowframework/util/ApiSpecFetcher.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/opensearch/flowframework/util/ApiSpecFetcher.java#L34

Added line #L34 was not covered by tests
private static final Logger logger = LogManager.getLogger(ApiSpecFetcher.class);
private static final ParseOptions OPENAPI_PARSER = new ParseOptions();
private static final OpenAPIV3Parser PARSER = new OpenAPIV3Parser();
owaiskazi19 marked this conversation as resolved.
Show resolved Hide resolved

static {
OPENAPI_PARSER.setResolve(true);
OPENAPI_PARSER.setResolveFully(true);
}

/**
* Parses the OpenAPI specification directly from the URI.
*
* @param apiSpecUri URI to the API specification (can be file path or web URI).
* @return Parsed OpenAPI object.
* @throws ApiSpecParseException If parsing fails.
*/
public static OpenAPI fetchApiSpec(String apiSpecUri) {
logger.info("Parsing API spec from URI: {}", apiSpecUri);
SwaggerParseResult result = PARSER.readLocation(apiSpecUri, null, OPENAPI_PARSER);
OpenAPI openApi = result.getOpenAPI();

if (openApi == null) {
throw new ApiSpecParseException("Unable to parse spec from URI: " + apiSpecUri, result.getMessages());
}

return openApi;
}

/**
* Compares the required fields in the API spec with the required enum parameters.
*
* @param requiredEnumParams List of required parameters from the enum.
* @param apiSpecUri URI of the API spec to fetch and compare.
* @param path The API path to check.
* @param method The HTTP method (POST, GET, etc.).
* @return boolean indicating if the required fields match.
*/
public static boolean compareRequiredFields(List<String> requiredEnumParams, String apiSpecUri, String path, RestRequest.Method method)
throws IllegalArgumentException, ApiSpecParseException {
OpenAPI openAPI = fetchApiSpec(apiSpecUri);

PathItem pathItem = openAPI.getPaths().get(path);
Content content = getContent(method, pathItem);
MediaType mediaType = content.get(XContentType.JSON.mediaTypeWithoutParameters());
if (mediaType != null) {
Schema<?> schema = mediaType.getSchema();

List<String> requiredApiParams = schema.getRequired();
if (requiredApiParams != null && !requiredApiParams.isEmpty()) {
return new HashSet<>(requiredEnumParams).equals(new HashSet<>(requiredApiParams));
}
}
return false;

Check warning on line 87 in src/main/java/org/opensearch/flowframework/util/ApiSpecFetcher.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/opensearch/flowframework/util/ApiSpecFetcher.java#L87

Added line #L87 was not covered by tests
}

private static Content getContent(RestRequest.Method method, PathItem pathItem) throws IllegalArgumentException, ApiSpecParseException {
junweid62 marked this conversation as resolved.
Show resolved Hide resolved
Operation operation;
switch (method) {
case POST:
operation = pathItem.getPost();
break;
case GET:
operation = pathItem.getGet();
break;
case PUT:
operation = pathItem.getPut();
break;

Check warning on line 101 in src/main/java/org/opensearch/flowframework/util/ApiSpecFetcher.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/opensearch/flowframework/util/ApiSpecFetcher.java#L100-L101

Added lines #L100 - L101 were not covered by tests
case DELETE:
operation = pathItem.getDelete();
break;
default:
throw new IllegalArgumentException("Unsupported HTTP method: " + method);
}

if (operation == null) {
throw new IllegalArgumentException("No operation found for the specified method: " + method);
}

RequestBody requestBody = operation.getRequestBody();
if (requestBody == null) {
throw new ApiSpecParseException("No requestBody defined for this operation.");
}

return requestBody.getContent();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/
package org.opensearch.flowframework.exception;

import org.opensearch.OpenSearchException;
import org.opensearch.test.OpenSearchTestCase;

import java.util.Arrays;
import java.util.List;

public class ApiSpecParseExceptionTests extends OpenSearchTestCase {

public void testApiSpecParseException() {
ApiSpecParseException exception = new ApiSpecParseException("API spec parsing failed");
assertTrue(exception instanceof OpenSearchException);
assertEquals("API spec parsing failed", exception.getMessage());
}

public void testApiSpecParseExceptionWithCause() {
Throwable cause = new RuntimeException("Underlying issue");
ApiSpecParseException exception = new ApiSpecParseException("API spec parsing failed", cause);
assertTrue(exception instanceof OpenSearchException);
assertEquals("API spec parsing failed", exception.getMessage());
assertEquals(cause, exception.getCause());
}

public void testApiSpecParseExceptionWithDetailedErrors() {
String message = "API spec parsing failed";
List<String> details = Arrays.asList("Missing required field", "Invalid type");
ApiSpecParseException exception = new ApiSpecParseException(message, details);
assertTrue(exception instanceof OpenSearchException);
String expectedMessage = "API spec parsing failed: Missing required field, Invalid type";
assertEquals(expectedMessage, exception.getMessage());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/
package org.opensearch.flowframework.util;

import org.opensearch.flowframework.exception.ApiSpecParseException;
import org.opensearch.rest.RestRequest;
junweid62 marked this conversation as resolved.
Show resolved Hide resolved
import org.opensearch.test.OpenSearchTestCase;
import org.junit.Before;

import java.util.Arrays;
import java.util.List;

import io.swagger.v3.oas.models.OpenAPI;

import static org.opensearch.flowframework.common.CommonValue.ML_COMMONS_API_SPEC_YAML_URI;

public class ApiSpecFetcherTests extends OpenSearchTestCase {

private ApiSpecFetcher apiSpecFetcher;

@Before
public void setUp() throws Exception {
super.setUp();
}

public void testFetchApiSpecSuccess() throws Exception {

OpenAPI result = ApiSpecFetcher.fetchApiSpec(ML_COMMONS_API_SPEC_YAML_URI);

assertNotNull("The fetched OpenAPI spec should not be null", result);
}

public void testFetchApiSpecThrowsException() throws Exception {
String invalidUri = "http://invalid-url.com/fail.yaml";

ApiSpecParseException exception = expectThrows(ApiSpecParseException.class, () -> { ApiSpecFetcher.fetchApiSpec(invalidUri); });

assertNotNull("Exception should be thrown for invalid URI", exception);
assertTrue(exception.getMessage().contains("Unable to parse spec"));
}

public void testCompareRequiredFieldsSuccess() throws Exception {

String path = "/_plugins/_ml/agents/_register";
RestRequest.Method method = RestRequest.Method.POST;

// Assuming REGISTER_AGENT step in the enum has these required fields
List<String> expectedRequiredParams = Arrays.asList("name", "type");

boolean comparisonResult = ApiSpecFetcher.compareRequiredFields(expectedRequiredParams, ML_COMMONS_API_SPEC_YAML_URI, path, method);

assertTrue("The required fields should match between API spec and enum", comparisonResult);
}

public void testCompareRequiredFieldsFailure() throws Exception {

String path = "/_plugins/_ml/agents/_register";
RestRequest.Method method = RestRequest.Method.POST;

List<String> wrongRequiredParams = Arrays.asList("nonexistent_param");

boolean comparisonResult = ApiSpecFetcher.compareRequiredFields(wrongRequiredParams, ML_COMMONS_API_SPEC_YAML_URI, path, method);

assertFalse("The required fields should not match for incorrect input", comparisonResult);
}

public void testCompareRequiredFieldsThrowsException() throws Exception {
String invalidUri = "http://invalid-url.com/fail.yaml";
String path = "/_plugins/_ml/agents/_register";
RestRequest.Method method = RestRequest.Method.POST;
junweid62 marked this conversation as resolved.
Show resolved Hide resolved

Exception exception = expectThrows(
Exception.class,
() -> { ApiSpecFetcher.compareRequiredFields(List.of(), invalidUri, path, method); }
);

assertNotNull("An exception should be thrown for an invalid API spec Uri", exception);
assertTrue(exception.getMessage().contains("Unable to parse spec"));
}

public void testUnsupportedMethodException() throws IllegalArgumentException {
Exception exception = expectThrows(Exception.class, () -> {
ApiSpecFetcher.compareRequiredFields(
List.of("name", "type"),
ML_COMMONS_API_SPEC_YAML_URI,
"/_plugins/_ml/agents/_register",
RestRequest.Method.PATCH
);
});

assertEquals("Unsupported HTTP method: PATCH", exception.getMessage());
}

public void testNoOperationFoundException() throws Exception {
Exception exception = expectThrows(IllegalArgumentException.class, () -> {
ApiSpecFetcher.compareRequiredFields(
List.of("name", "type"),
ML_COMMONS_API_SPEC_YAML_URI,
"/_plugins/_ml/agents/_register",
RestRequest.Method.DELETE // PATCH should be unsupported
);
});

assertEquals("No operation found for the specified method: DELETE", exception.getMessage());
}

public void testNoRequestBodyDefinedException() throws ApiSpecParseException {
Exception exception = expectThrows(ApiSpecParseException.class, () -> {
ApiSpecFetcher.compareRequiredFields(
List.of("name", "type"),
ML_COMMONS_API_SPEC_YAML_URI,
"/_plugins/_ml/model_groups/{model_group_id}",
RestRequest.Method.GET
);
});

assertEquals("No requestBody defined for this operation.", exception.getMessage());
}

}
Loading
Loading