Skip to content

Commit

Permalink
feat(#1175): Add OpenApiRepository and Validation
Browse files Browse the repository at this point in the history
  • Loading branch information
Thorsten Schlathoelter authored and bbortt committed Dec 9, 2024
1 parent c61d53e commit d5d68fe
Show file tree
Hide file tree
Showing 52 changed files with 4,180 additions and 605 deletions.
11 changes: 5 additions & 6 deletions connectors/citrus-openapi/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
<groupId>io.apicurio</groupId>
<artifactId>apicurio-data-models</artifactId>
</dependency>
<dependency>
<groupId>com.atlassian.oai</groupId>
<artifactId>swagger-request-validator-core</artifactId>
<version>2.40.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
Expand Down Expand Up @@ -76,12 +81,6 @@
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.17.0</version>
<scope>compile</scope>
</dependency>
</dependencies>

<profiles>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/*
* Copyright the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.citrusframework.openapi;

import static java.lang.String.format;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import org.citrusframework.exceptions.CitrusRuntimeException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* A registry to store objects by OpenApi paths. The registry uses a digital tree data structure
* that performs path matching with variable placeholders. Variable
* placeholders must be enclosed in curly braces '{}', e.g., '/api/v1/pet/{id}'. This data structure
* is optimized for matching paths efficiently, handling both static and dynamic segments.
* <p>
* This class is currently not in use but may serve scenarios where a path needs to be mapped to an
* OasOperation without explicit knowledge of the API to which the path belongs.
* It could be utilized, for instance, in implementing an OAS message validator based on
* {@link org.citrusframework.validation.AbstractMessageValidator}.
*/
public class OpenApiPathRegistry<T> {

private static final Logger logger = LoggerFactory.getLogger(OpenApiPathRegistry.class);

private final RegistryNode root = new RegistryNode();

private final Map<String, T> allPaths = new ConcurrentHashMap<>();

public T search(String path) {
RegistryNode trieNode = internalSearch(path);
return trieNode != null ? trieNode.value : null;
}

RegistryNode internalSearch(String path) {
String[] segments = path.split("/");
return searchHelper(root, segments, 0);
}

public boolean insert(String path, T value) {
return insertInternal(path, value) != null;
}

RegistryNode insertInternal(String path, T value) {

if (path == null || value == null) {
return null;
}

String[] segments = path.split("/");
RegistryNode node = root;

if (!allPaths.isEmpty() && (isPathAlreadyContainedWithDifferentValue(path, value)
|| isPathMatchedByOtherPath(path, value))) {
return null;
}

allPaths.put(path, value);
StringBuilder builder = new StringBuilder();
for (String segment : segments) {
if (builder.isEmpty() || builder.charAt(builder.length() - 1) != '/') {
builder.append("/");
}
builder.append(segment);

if (!node.children.containsKey(segment)) {
RegistryNode trieNode = new RegistryNode();
trieNode.path = builder.toString();
node.children.put(segment, trieNode);
}
node = node.children.get(segment);
}

// Sanity check to disallow overwrite of existing values
if (node.value != null && !node.value.equals(value)) {
throw new CitrusRuntimeException(format(
"Illegal attempt to overwrite an existing node value. This is probably a bug. path=%s value=%s",
node.path, node.value));
}
node.value = value;

return node;
}

/**
* Tests if the path is either matching an existing path or any existing path matches the given
* patch.
* <p>
* For example '/a/b' does not match '/{a}/{b}', but '/{a}/{b}' matches '/a/b'.
*/
private boolean isPathMatchedByOtherPath(String path, T value) {

// Does the given path match any existing
RegistryNode currentValue = internalSearch(path);
if (currentValue != null && !Objects.equals(path, currentValue.path)) {
logger.error(
"Attempt to insert an equivalent path potentially overwriting an existing value. Value for path is ignored: path={}, value={} currentValue={} ",
path, currentValue, value);
return true;
}

// Does any existing match the path.
OpenApiPathRegistry<T> tmpTrie = new OpenApiPathRegistry<>();
tmpTrie.insert(path, value);

List<String> allMatching = allPaths.keySet().stream()
.filter(existingPath -> {
RegistryNode trieNode = tmpTrie.internalSearch(existingPath);
return trieNode != null && !existingPath.equals(trieNode.path);
}).map(existingPath -> "'" + existingPath + "'").toList();
if (!allMatching.isEmpty() && logger.isErrorEnabled()) {
logger.error(
"Attempt to insert an equivalent path overwritten by existing paths. Value for path is ignored: path={}, value={} existingPaths=[{}]",
path, currentValue, String.join(",", allMatching));

}

return !allMatching.isEmpty();
}

private boolean isPathAlreadyContainedWithDifferentValue(String path, T value) {
T currentValue = allPaths.get(path);
if (currentValue != null) {
if (value.equals(currentValue)) {
return false;
}
logger.error(
"Attempt to overwrite value for path is ignored: path={}, value={} currentValue={} ",
path, currentValue, value);
return true;
}
return false;
}

private RegistryNode searchHelper(RegistryNode node, String[] segments, int index) {
if (node == null) {
return null;
}
if (index == segments.length) {
return node;
}

String segment = segments[index];

// Exact match
if (node.children.containsKey(segment)) {
RegistryNode foundNode = searchHelper(node.children.get(segment), segments, index + 1);
if (foundNode != null && foundNode.value != null) {
return foundNode;
}
}

// Variable match
for (String key : node.children.keySet()) {
if (key.startsWith("{") && key.endsWith("}")) {
RegistryNode foundNode = searchHelper(node.children.get(key), segments, index + 1);
if (foundNode != null && foundNode.value != null) {
return foundNode;
}
}
}

return null;
}

class RegistryNode {
Map<String, RegistryNode> children = new HashMap<>();
String path;
T value = null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,45 @@

package org.citrusframework.openapi;

import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import org.citrusframework.repository.BaseRepository;
import org.citrusframework.spi.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* OpenApi repository holding a set of {@link OpenApiSpecification} known in the test scope.
*
* @since 4.4.0
*/
public class OpenApiRepository extends BaseRepository {

private static final Logger logger = LoggerFactory.getLogger(OpenApiRepository.class);

private static final String DEFAULT_NAME = "openApiSchemaRepository";

/** List of schema resources */
/**
* List of schema resources
*/
private final List<OpenApiSpecification> openApiSpecifications = new ArrayList<>();


/** An optional context path, used for each api, without taking into account any {@link OpenApiSpecification} specific context path. */
/**
* An optional context path, used for each api, without taking into account any
* {@link OpenApiSpecification} specific context path.
*/
private String rootContextPath;

private boolean requestValidationEnabled = true;

private boolean responseValidationEnabled = true;

public OpenApiRepository() {
super(DEFAULT_NAME);
}
Expand All @@ -48,19 +67,80 @@ public void setRootContextPath(String rootContextPath) {
this.rootContextPath = rootContextPath;
}

public boolean isRequestValidationEnabled() {
return requestValidationEnabled;
}

public void setRequestValidationEnabled(boolean requestValidationEnabled) {
this.requestValidationEnabled = requestValidationEnabled;
}

public boolean isResponseValidationEnabled() {
return responseValidationEnabled;
}

public void setResponseValidationEnabled(boolean responseValidationEnabled) {
this.responseValidationEnabled = responseValidationEnabled;
}

/**
* Adds an OpenAPI Specification specified by the given resource to the repository.
* If an alias is determined from the resource name, it is added to the specification.
*
* @param openApiResource the resource to add as an OpenAPI specification
*/
@Override
public void addRepository(Resource openApiResource) {

OpenApiSpecification openApiSpecification = OpenApiSpecification.from(openApiResource);
determineResourceAlias(openApiResource).ifPresent(openApiSpecification::addAlias);
openApiSpecification.setRequestValidationEnabled(requestValidationEnabled);
openApiSpecification.setResponseValidationEnabled(responseValidationEnabled);
openApiSpecification.setRootContextPath(rootContextPath);

this.openApiSpecifications.add(openApiSpecification);

OpenApiSpecificationProcessor.lookup().values().forEach(processor -> processor.process(openApiSpecification));
OpenApiSpecificationProcessor.lookup().values()
.forEach(processor -> processor.process(openApiSpecification));
}

/**
* @param openApiResource the OpenAPI resource from which to determine the alias
* @return an {@code Optional} containing the resource alias if it can be resolved, otherwise an empty {@code Optional}
*/
// Package protection for testing
static Optional<String> determineResourceAlias(Resource openApiResource) {
String resourceAlias = null;

try {
File file = openApiResource.getFile();
if (file != null) {
return Optional.of(file.getName());
}
} catch (Exception e) {
// Ignore and try with url
}

try {
URL url = openApiResource.getURL();
if (url != null) {
String urlString = URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8).replace("\\","/");
int index = urlString.lastIndexOf("/");
resourceAlias = urlString;
if (index != -1 && index != urlString.length()-1) {
resourceAlias = resourceAlias.substring(index+1);
}
}
} catch (MalformedURLException e) {
logger.error("Unable to determine resource alias from resource!", e);
}

return Optional.ofNullable(resourceAlias);
}

public List<OpenApiSpecification> getOpenApiSpecifications() {
return openApiSpecifications;
}



}
Loading

0 comments on commit d5d68fe

Please sign in to comment.