diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dc4817aca..4656e9b3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,6 +56,30 @@ jobs: annotate_only: true include_passed: true report_paths: '**/TEST-*.xml' + open-api-static-analysis: + name: OpenApi Static Analysis Tests + runs-on: ubuntu-latest + env: + OPEN_API_TEST: "true" + steps: + - name: checkout code + uses: actions/checkout@v5.0.0 + - name: setup java + uses: actions/setup-java@v5.0.0 + with: + distribution: 'temurin' + java-version: 11 + cache: 'gradle' + - name: build and test + id: thebuild + run: ./gradlew :cwms-data-api:test --tests "cwms.cda.api.OpenApiDocTest" --info --init-script init.gradle + - name: Publish Test Report + uses: mikepenz/action-junit-report@v5 + if: always() + with: + annotate_only: true + include_passed: true + report_paths: '**/TEST-*.xml' build-docker-image: runs-on: ubuntu-latest steps: diff --git a/cwms-data-api/build.gradle b/cwms-data-api/build.gradle index 2169f3219..bd67a53ee 100644 --- a/cwms-data-api/build.gradle +++ b/cwms-data-api/build.gradle @@ -159,6 +159,8 @@ dependencies { // override versions implementation(libs.bundles.overrides) + + testImplementation(libs.bundles.java.parser) } task extractWebJars(type: Copy) { diff --git a/cwms-data-api/src/test/java/cwms/cda/api/OpenApiDocTest.java b/cwms-data-api/src/test/java/cwms/cda/api/OpenApiDocTest.java new file mode 100644 index 000000000..2d365d4c0 --- /dev/null +++ b/cwms-data-api/src/test/java/cwms/cda/api/OpenApiDocTest.java @@ -0,0 +1,523 @@ +/* + * MIT License + * Copyright (c) 2025 Hydrologic Engineering Center + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.api; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.ImportDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.Parameter; +import com.github.javaparser.ast.expr.ClassExpr; +import com.github.javaparser.ast.expr.Expression; +import com.github.javaparser.ast.expr.FieldAccessExpr; +import com.github.javaparser.ast.expr.MethodCallExpr; +import com.github.javaparser.ast.expr.NameExpr; +import com.github.javaparser.resolution.Resolvable; +import com.github.javaparser.resolution.declarations.ResolvedValueDeclaration; +import com.google.common.flogger.FluentLogger; +import helpers.OpenApiDocInfo; +import helpers.OpenApiDocTestInfo; +import helpers.OpenApiParamInfo; +import helpers.OpenApiParamUsage; +import helpers.OpenApiParamUsageInfo; +import helpers.OpenApiTestHelper; +import io.javalin.apibuilder.CrudHandler; +import io.javalin.http.Handler; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.servlet.http.HttpServletResponse; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.*; + +@EnabledIfEnvironmentVariable(named = "OPEN_API_TEST", matches = "true") +class OpenApiDocTest { + + private static final FluentLogger LOGGER = FluentLogger.forEnclosingClass(); + + @MethodSource(value = "getHandlerDocInfo") + @ParameterizedTest + void test_handler_documentation(OpenApiDocTestInfo testInfo) throws IOException { + CompilationUnit compilationUnit = OpenApiTestHelper.readCompilationUnit(testInfo.getClazz()); + assertAll(buildTestAssertions(compilationUnit, testInfo)); + } + + @MethodSource(value = "getCrudHandlerDocInfo") + @ParameterizedTest + void test_crud_handler_documentation(OpenApiDocTestInfo testInfo) throws IOException { + CompilationUnit compilationUnit = OpenApiTestHelper.readCompilationUnit(testInfo.getClazz()); + assertAll(buildTestAssertions(compilationUnit, testInfo)); + } + + @Test + void test_time_series_controller() throws IOException { + OpenApiDocTestInfo testInfo = OpenApiTestHelper.readOpenApiDocs(CrudHandler.class, StateController.class); + CompilationUnit compilationUnit = OpenApiTestHelper.readCompilationUnit(testInfo.getClazz()); + assertAll(buildTestAssertions(compilationUnit, testInfo)); + } + + private Stream buildTestAssertions(CompilationUnit compilationUnit, OpenApiDocTestInfo testInfo) { + return testInfo.getMethodDocs() + .stream() + .map(docInfo -> validateOpenApiDoc(compilationUnit, docInfo, testInfo.getClazz())); + } + + private Executable validateOpenApiDoc(CompilationUnit unit, OpenApiDocInfo testInfo, Class clazz){ + Executable output; + if (testInfo.isIgnored()) { + output = testIgnoredMethod(unit, testInfo, clazz); + } else { + OpenApiParamUsage parsedParamInfo = parseParamInfo(unit, clazz, testInfo.getMethod()); + output = testMethod(testInfo, parsedParamInfo); + } + return output; + } + + private Executable testIgnoredMethod(CompilationUnit unit, OpenApiDocInfo testInfo, Class clazz) { + + // Expected format for ignored methods is just one call to: + // `ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED).json(CdaError.notImplemented());` + MethodDeclaration method = getMethodDeclaration(unit, testInfo.getMethod()); + + Optional statusCall = method.findAll(MethodCallExpr.class) + .stream() + .filter(exp -> exp.getNameAsString().equals("status")) + .findFirst(); + + Optional jsonCall = method.findAll(MethodCallExpr.class) + .stream() + .filter(exp -> exp.getNameAsString().equals("json")) + .findFirst(); + + try { + boolean usesStatus = statusCall.isPresent(); + boolean isCorrectCode = statusCall.stream() + .map(exp -> parseParameterName(exp.getArgument(0))) + .mapToInt(Integer::parseInt) + .anyMatch(v -> v == HttpServletResponse.SC_NOT_IMPLEMENTED); + + boolean usesJson = jsonCall.isPresent(); + boolean isCorrectJson = jsonCall.map(exp -> parseParameterName(exp.getArgument(0))) + .map("CdaError.notImplemented()"::equals) + .orElse(false); + return () -> assertAll( + "Testing ignored method " + method.getNameAsString() + ": Incorrect response for ignored endpoint. Expecting `ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED).json(CdaError.notImplemented())`", + () -> assertTrue(usesStatus && isCorrectCode, + "Incorrect status code used, context should provide HttpServletResponse.SC_NOT_IMPLEMENTED."), + () -> assertTrue(usesJson && isCorrectJson, + "Incorrect JSON returned, context should respond with CdaError.notImplemented()")); + } catch (Exception ex) { + return () -> fail("Testing ignored method " + method.getNameAsString() + ": Error analyzing method. Expected `ctx.status(HttpServletResponse.SC_NOT_IMPLEMENTED).json(CdaError.notImplemented());`.", ex); + } + } + + private Executable testMethod(OpenApiDocInfo testInfo, + OpenApiParamUsage parsedParamInfo) { + List expectedQueryParameters = testInfo.getQueryParameters(); + List expectedPathParameters = testInfo.getPathParameters(); + + Set receivedQueryParameters = parsedParamInfo.getQueryParams(); + Set receivedPathParameters = parsedParamInfo.getPathParams(); + OpenApiParamUsageInfo receivedResourceId = parsedParamInfo.getResourceId(); + return () -> assertAll("Testing " + testInfo.getMethod().getName(), + () -> testQueryParameters(expectedQueryParameters, receivedQueryParameters), + () -> testPathParameters(expectedPathParameters, receivedPathParameters, receivedResourceId)); + } + + private void testPathParameters(List expectedPathParameters, Set receivedPathParameters, + OpenApiParamUsageInfo receivedResourceId) { + Set verifiedUsages = new HashSet<>(); + Set expectedParams = new HashSet<>(); + Set missingItems = new HashSet<>(); + Set receivedItems = new HashSet<>(receivedPathParameters); + + if (receivedResourceId != null) { + //Special case, equivalent to the last expectedPathParameters, but it can have an ambiguous name. + if (!expectedPathParameters.isEmpty()) { + String name = expectedPathParameters.get(expectedPathParameters.size() - 1).getName(); + receivedResourceId.getParamInfo().setName(name); + } + + receivedItems.add(receivedResourceId); + } + + for (OpenApiParamInfo paramInfo : expectedPathParameters) { + OpenApiParamUsageInfo equivalent = null; + for (OpenApiParamUsageInfo paramUsage : receivedItems) { + if (paramUsage.getParamInfo().getName().equals(paramInfo.getName())) { + equivalent = paramUsage; + break; + } + } + if (equivalent == null) { + missingItems.add(paramInfo); + } else { + receivedItems.remove(equivalent); + verifiedUsages.add(equivalent); + expectedParams.add(paramInfo); + } + } + + String extraInfo = receivedItems.stream() + .map(p -> p.getParamInfo().getName()) + .collect(Collectors.joining(", ")); + String missingInfo = missingItems.stream() + .map(OpenApiParamInfo::getName) + .collect(Collectors.joining(", ")); + assertAll(() -> assertTrue(receivedItems.isEmpty(), "Found used undocumented path parameter: " + extraInfo), + () -> assertTrue(missingItems.isEmpty(), "Found documented path parameter that is not used: " + missingInfo), + () -> assertAll(expectedParams.stream().map(expectedParam -> testParamInfo(expectedParam, verifiedUsages)))); + } + + private void testQueryParameters(List expectedQueryParameters, + Set receivedQueryParameters) { + Set verifiedUsages = new HashSet<>(); + Set expectedParams = new HashSet<>(); + Set missingItems = new HashSet<>(); + Set receivedItems = new HashSet<>(receivedQueryParameters); + + for (OpenApiParamInfo paramInfo : expectedQueryParameters) { + OpenApiParamUsageInfo equivalent = null; + for (OpenApiParamUsageInfo paramUsage : receivedItems) { + if (paramUsage.getParamInfo().getName().equals(paramInfo.getName())) { + equivalent = paramUsage; + break; + } + } + if (equivalent == null) { + missingItems.add(paramInfo); + } else { + receivedItems.remove(equivalent); + verifiedUsages.add(equivalent); + expectedParams.add(paramInfo); + } + } + + String extraInfo = receivedItems.stream() + .map(p -> p.getParamInfo().getName()) + .collect(Collectors.joining(", ")); + String missingInfo = missingItems.stream() + .map(OpenApiParamInfo::getName) + .collect(Collectors.joining(", ")); + assertAll(() -> assertTrue(receivedItems.isEmpty(), "Found used undocumented query parameter: " + extraInfo), + () -> assertTrue(missingItems.isEmpty(), "Found documented query parameter that is not used: " + missingInfo), + () -> assertAll(expectedParams.stream().map(expectedParam -> testParamInfo(expectedParam, verifiedUsages)))); + } + + private Executable testParamInfo(OpenApiParamInfo expectedParam, + Set receivedQueryParameters) { + OpenApiParamUsageInfo receivedInfo = receivedQueryParameters.stream() + .filter(receivedUsageInfo -> receivedUsageInfo.getParamInfo() + .getName() + .equals(expectedParam.getName())) + .findFirst() + .orElse(null); + + //This should not happen, just a sanity check + assertNotNull(receivedInfo, "Unable to find " + expectedParam.getName() + " in the code."); + + //Real tests + return () -> assertAll(() -> assertTrue(receivedInfo.isUsed(), "Unable to find a usage of documented parameter: " + expectedParam.getName()), + () -> assertTrue(receivedInfo.isNullHandled(), "Unable to find a null handled usage of documented parameter: " + expectedParam.getName())); + } + + private OpenApiParamUsage parseParamInfo(CompilationUnit unit, Class clazz, Method method) { + MethodDeclaration methodDeclaration = getMethodDeclaration(unit, method); + String context = methodDeclaration.getParameter(0).getNameAsString(); + + List methodCalls = methodDeclaration.findAll(MethodCallExpr.class); + Set optionalTypedQueryParams = readParamUsagesFromCall(methodCalls, call -> readQueryParamAsClassFromCall(unit, context, clazz, call), "queryParamAsClass"); + Set optionalDoubleQueryParams = readParamUsagesFromCall(methodCalls, call -> readUsageFromCall(unit, clazz, call, false), "queryParamAsDouble"); + + Set optionalStringQueryParams = methodCalls.stream() + .filter(call -> call.getNameAsString().equals("queryParam")) + .map(call -> readUsageFromCall(unit, clazz, call, false)) + .collect(Collectors.toSet()); + + Set requiredQueryParams = methodCalls.stream() + .filter(call -> call.getNameAsString().equals("requiredParam") || + call.getNameAsString().equals("requiredParamAs")) + .map(call -> readUsageFromCall(unit, clazz, call, true)) + .collect(Collectors.toSet()); + + Set optionalTimeQueryParams = methodCalls.stream() + .filter(call -> call.getNameAsString().equals("queryParamAsInstant") || + call.getNameAsString().equals("queryParamAsZdt")) + .map(call -> readJavaTimeFromCall(call, false)) + .flatMap(Set::stream) + .collect(Collectors.toSet()); + + Set requiredTimeQueryParams = methodCalls.stream() + .filter(call -> call.getNameAsString().equals("requiredZdt") || + call.getNameAsString().equals("requiredInstant")) + .map(call -> readJavaTimeFromCall(call, true)) + .flatMap(Set::stream) + .collect(Collectors.toSet()); + + Set queryParams = new HashSet<>(optionalStringQueryParams); + queryParams.addAll(optionalTypedQueryParams); + queryParams.addAll(requiredQueryParams); + queryParams.addAll(optionalTimeQueryParams); + queryParams.addAll(requiredTimeQueryParams); + queryParams.addAll(optionalDoubleQueryParams); + + + Set pathParams = methodCalls.stream() + .filter(call -> call.getNameAsString().equals("pathParam")) + .map(call -> readUsageFromCall(unit, clazz, call, true)) + .collect(Collectors.toSet()); + + OpenApiParamUsageInfo resourceId = null; + + if (methodDeclaration.getParameters().size() > 1) { + Parameter param = methodDeclaration.getParameter(1); + String paramId = param.getNameAsString(); + OpenApiParamInfo paramInfo = new OpenApiParamInfo(paramId, true, String.class); + boolean isUsed = methodDeclaration.findAll(MethodCallExpr.class) + .stream() + .anyMatch(call -> call.toString().contains(paramId)); + resourceId = new OpenApiParamUsageInfo(paramInfo, isUsed, true); + } + + return new OpenApiParamUsage(pathParams, queryParams, resourceId); + } + + private OpenApiParamUsageInfo readQueryParamAsClassFromCall(CompilationUnit unit, String context, Class clazz, MethodCallExpr call) { + return call.getScope() + .map(scope -> { + if (scope.isNameExpr()) { + return readQueryParamAsClassFromContextCall(unit, clazz, call); + } else { + return readQueryParamAsClassFromControllersCall(unit, clazz, call); + } + }).orElseGet(() -> readQueryParamAsClassFromControllersCall(unit, clazz, call)); + } + + private OpenApiParamUsageInfo readQueryParamAsClassFromContextCall(CompilationUnit unit, Class clazz, MethodCallExpr call) { + // First argument is the parameter name (usually a string literal or constant) + String paramName = parseParameterName(call.getArgument(0)); + + Class paramClass = String.class; + if (call.getArguments().size() > 1) { + // Second argument is the class (e.g., Boolean.class, String.class) + ClassExpr argument = call.getArgument(1).asClassExpr(); + paramClass = identifyClassFromExpression(unit, clazz, argument); + } + boolean used = true; + boolean nullHandled = true; + return new OpenApiParamUsageInfo(new OpenApiParamInfo(paramName, false, paramClass), used, nullHandled); + } + + private OpenApiParamUsageInfo readQueryParamAsClassFromControllersCall(CompilationUnit unit, Class clazz, MethodCallExpr call) { + Expression arg1 = call.getArgument(1); + Class type; + String name; + if (arg1.isArrayCreationExpr()) { + //Context, String[], Class, T, {metrics}, {className} + type = identifyClassFromExpression(unit, clazz, call.getArgument(2).asClassExpr()); + name = parseParameterName(arg1.asArrayCreationExpr().getInitializer().orElse(null).getValues().get(0)); + } else if (arg1.isClassExpr()) { + //Context, Class, T, Name, [Aliases] + type = identifyClassFromExpression(unit, clazz, arg1.asClassExpr()); + name = parseParameterName(call.getArgument(3)); + } else { + //Unknown case for queryParamAsClass (new method to handle? + throw new UnsupportedOperationException("Unsupported argument[1] type for queryParamAsClass: " + arg1.getClass()); + } + + return new OpenApiParamUsageInfo(new OpenApiParamInfo(name, false, type), true, true); + } + + private Set readParamUsagesFromCall(List methodCalls, + Function paramReader, + String... functions) { + List realFunctions = Arrays.asList(functions); + return methodCalls.stream() + .filter(call -> realFunctions.contains(call.getNameAsString())) + .map(paramReader) + .collect(Collectors.toSet()); + } + + private Set readJavaTimeFromCall(MethodCallExpr call, boolean required) { + //Should only be 2 parameters, and parameter 2 is the parameter name + String paramName = parseParameterName(call.getArgument(1)); + Class type = String.class; + boolean used = true; + boolean nullHandled = true; + return Set.of(new OpenApiParamUsageInfo(new OpenApiParamInfo(paramName, required, type), used, nullHandled), + new OpenApiParamUsageInfo(new OpenApiParamInfo(Controllers.TIMEZONE, required, type), used, nullHandled)); + } + + private OpenApiParamUsageInfo readUsageFromCall(CompilationUnit unit, Class clazz, MethodCallExpr call, boolean required) { + //We have a scope, so it's called from something like context. + return call.getScope().map(exp -> { + // First argument is the parameter name (usually a string literal or constant) + String paramName = parseParameterName(call.getArgument(0)); + + Class paramClass = String.class; + if (call.getArguments().size() > 1) { + // Second argument is the class (e.g., Boolean.class, String.class) + ClassExpr argument = call.getArgument(1).asClassExpr(); + paramClass = identifyClassFromExpression(unit, clazz, argument); + } + boolean used = true; + boolean nullHandled = true; + if (!required) { + //Check if null is handled via getOrDefault + } + return new OpenApiParamUsageInfo(new OpenApiParamInfo(paramName, required, paramClass), used, nullHandled); + }).orElseGet(() -> { + //It's calling a function, so most likely argument 0 is context, argument 1 is the identifier, and argument 2 is class. + String paramName = parseParameterName(call.getArgument(1)); + Class paramClass = String.class; + if (call.getArguments().size() > 2) { + ClassExpr argument = call.getArgument(2).asClassExpr(); + paramClass = identifyClassFromExpression(unit, clazz, argument); + } else if (call.getNameAsString().endsWith("AsDouble")) { + paramClass = Double.class; + } else if (call.getNameAsString().endsWith("AsString")) { + paramClass = String.class; + } + boolean nullHandled = true; + return new OpenApiParamUsageInfo(new OpenApiParamInfo(paramName, required, paramClass), true, nullHandled); + }); + } + + private @NotNull String parseParameterName(Expression arg) { + String value; + if (arg.isStringLiteralExpr()) { + // Using a literal, which is problematic on its own, but we can get the value. + value = arg.asStringLiteralExpr().getValue(); + } else if (arg.isFieldAccessExpr()) { + // It's using a field accessor - this means it's calling Controllers.NAME for instance. + // This doesn't apply to when the field is statically imported though. + FieldAccessExpr exp = arg.asFieldAccessExpr(); + value = resolveValue(exp, exp.getNameAsString()); + } else if (arg.isNameExpr()) { + NameExpr exp = arg.asNameExpr(); + value = resolveValue(exp, exp.getNameAsString()); + } else { + value = arg.toString(); + } + return value; + } + + private String resolveValue(Resolvable exp, String name) { + try { + ResolvedValueDeclaration resolve = exp.resolve(); + if (resolve.isField()) { + Class clazz = Class.forName(resolve.asField().declaringType().getQualifiedName()); + Field field = clazz.getDeclaredField(name); + field.setAccessible(true); + return field.get(null).toString(); + } else if (resolve.isEnumConstant()) { + return resolve.asEnumConstant().getName(); + } else { + throw new UnsupportedOperationException("Unable to parse resolved value declaration type of " + resolve.getClass().getName()); + } + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + private Class identifyClassFromExpression(CompilationUnit unit, Class clazz, ClassExpr expression) { + // This may or may not give us the fully qualified class name (depends on if the code uses that) + String className = expression.getTypeAsString(); + try { + return Class.forName(className); + } catch (ClassNotFoundException e) { + LOGGER.atFinest().withCause(e).log("Ignored, checking more imports."); + } + + // Check java.lang classes, since they don't need imports. + try { + return Class.forName("java.lang." + className); + } catch (ClassNotFoundException e) { + LOGGER.atFinest().withCause(e).log("Ignored, checking more imports."); + } + + // Check current package, since it doesn't need imports either + try { + return Class.forName(clazz.getPackageName() + "." + className); + } catch (ClassNotFoundException e) { + LOGGER.atFinest().withCause(e).log("Ignored, checking more imports."); + } + + Class output = null; + + for (ImportDeclaration importDeclaration : unit.getImports()) { + String name = importDeclaration.getNameAsString(); + if (name.endsWith("." + className)) { + try { + output = Class.forName(name); + break; + } catch (ClassNotFoundException e) { + //Not sure how this happened...seems bad + LOGGER.atSevere().withCause(e).log("Unable to find class for name " + name + "."); + } + } + + if (importDeclaration.isAsterisk()) { + try { + output = Class.forName(name + className); + break; + } catch (ClassNotFoundException e) { + LOGGER.atFinest().withCause(e).log("Ignored, checking more imports."); + } + } + } + + return output; + } + + private static MethodDeclaration getMethodDeclaration(CompilationUnit compilationUnit, Method method) { + return compilationUnit.findAll(MethodDeclaration.class) + .stream() + .filter(m -> m.getNameAsString().equals(method.getName())) + .filter(m -> m.getParameters().size() == method.getParameterCount()) + .findFirst() + .orElseThrow(() -> new AssertionError("Method " + method.getName() + " not found")); + } + + static Stream getHandlerDocInfo() { + List> handlers = OpenApiTestHelper.findClassesOfType(Handler.class); + return handlers.stream() + .map(clazz -> OpenApiTestHelper.readOpenApiDocs(Handler.class, clazz)); + } + + static Stream getCrudHandlerDocInfo() { + List> handlers = OpenApiTestHelper.findClassesOfType(CrudHandler.class); + return handlers.stream() + .map(clazz -> OpenApiTestHelper.readOpenApiDocs(CrudHandler.class, clazz)); + } +} diff --git a/cwms-data-api/src/test/java/helpers/OpenApiDocInfo.java b/cwms-data-api/src/test/java/helpers/OpenApiDocInfo.java new file mode 100644 index 000000000..b7d6057d4 --- /dev/null +++ b/cwms-data-api/src/test/java/helpers/OpenApiDocInfo.java @@ -0,0 +1,59 @@ +/* + * MIT License + * Copyright (c) 2025 Hydrologic Engineering Center + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package helpers; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +public class OpenApiDocInfo { + private final Method method; + private final List queryParameters = new ArrayList<>(); + private final List pathParameters = new ArrayList<>(); + private final boolean ignored; + + public OpenApiDocInfo(Method method, boolean ignored) { + this.method = method; + this.ignored = ignored; + } + + public Method getMethod() { + return method; + } + + public List getPathParameters() { + return pathParameters; + } + + public List getQueryParameters() { + return queryParameters; + } + + public boolean isIgnored() { + return ignored; + } + + @Override + public String toString() { + String temp = ignored ? " - Ignored" : ""; + return method.getName() + temp; + } +} diff --git a/cwms-data-api/src/test/java/helpers/OpenApiDocTestInfo.java b/cwms-data-api/src/test/java/helpers/OpenApiDocTestInfo.java new file mode 100644 index 000000000..21da3189c --- /dev/null +++ b/cwms-data-api/src/test/java/helpers/OpenApiDocTestInfo.java @@ -0,0 +1,51 @@ +/* + * MIT License + * Copyright (c) 2025 Hydrologic Engineering Center + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package helpers; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.stream.Collectors; + +public class OpenApiDocTestInfo { + private final Class clazz; + private final List methodDocs; + + public OpenApiDocTestInfo(Class clazz, List methodDocs) { + this.clazz = clazz; + this.methodDocs = methodDocs; + } + + public List getMethodDocs() { + return methodDocs; + } + + public Class getClazz() { + return clazz; + } + + @Override + public String toString() { + return clazz.getSimpleName() + ": " + methodDocs.stream() + .map(OpenApiDocInfo::getMethod) + .map(Method::getName) + .collect(Collectors.joining(", ")); + } +} diff --git a/cwms-data-api/src/test/java/helpers/OpenApiParamInfo.java b/cwms-data-api/src/test/java/helpers/OpenApiParamInfo.java new file mode 100644 index 000000000..ef85450d6 --- /dev/null +++ b/cwms-data-api/src/test/java/helpers/OpenApiParamInfo.java @@ -0,0 +1,73 @@ +/* + * MIT License + * Copyright (c) 2025 Hydrologic Engineering Center + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package helpers; + +import java.util.Objects; + +public class OpenApiParamInfo { + private String name; + private final boolean required; + private final Class type; + + public OpenApiParamInfo(String name, boolean required, Class type) { + this.name = name; + this.required = required; + this.type = type; + } + + public OpenApiParamInfo setName(String name) { + this.name = name; + return this; + } + + public String getName() { + return name; + } + + public boolean isRequired() { + return required; + } + + public Class getType() { + return type; + } + + @Override + public String toString() { + String req = required ? " (required)" : ""; + String clazz = type != null ? type.getSimpleName() : "null"; + return clazz + " " + name + req; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof OpenApiParamInfo)) { + return false; + } + OpenApiParamInfo that = (OpenApiParamInfo) o; + return Objects.equals(getName(), that.getName()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getName()); + } +} diff --git a/cwms-data-api/src/test/java/helpers/OpenApiParamUsage.java b/cwms-data-api/src/test/java/helpers/OpenApiParamUsage.java new file mode 100644 index 000000000..c76a720c4 --- /dev/null +++ b/cwms-data-api/src/test/java/helpers/OpenApiParamUsage.java @@ -0,0 +1,65 @@ +/* + * MIT License + * Copyright (c) 2025 Hydrologic Engineering Center + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package helpers; + +import java.util.Objects; +import java.util.Set; + +public class OpenApiParamUsage { + private final Set queryParams; + private final Set pathParams; + private final OpenApiParamUsageInfo resourceId; + + public OpenApiParamUsage(Set pathParams, Set queryParams, + OpenApiParamUsageInfo resourceId) { + this.pathParams = pathParams; + this.queryParams = queryParams; + this.resourceId = resourceId; + } + + public Set getPathParams() { + return pathParams; + } + + public Set getQueryParams() { + return queryParams; + } + + public OpenApiParamUsageInfo getResourceId() { + return resourceId; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof OpenApiParamUsage)) { + return false; + } + OpenApiParamUsage that = (OpenApiParamUsage) o; + return Objects.equals(getQueryParams(), that.getQueryParams()) && Objects.equals(getPathParams(), + that.getPathParams()) && Objects.equals( + getResourceId(), that.getResourceId()); + } + + @Override + public int hashCode() { + return Objects.hash(getQueryParams(), getPathParams(), getResourceId()); + } +} diff --git a/cwms-data-api/src/test/java/helpers/OpenApiParamUsageInfo.java b/cwms-data-api/src/test/java/helpers/OpenApiParamUsageInfo.java new file mode 100644 index 000000000..a6f502f06 --- /dev/null +++ b/cwms-data-api/src/test/java/helpers/OpenApiParamUsageInfo.java @@ -0,0 +1,67 @@ +/* + * MIT License + * Copyright (c) 2025 Hydrologic Engineering Center + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package helpers; + +import java.util.Objects; + +public class OpenApiParamUsageInfo { + private final OpenApiParamInfo paramInfo; + private final boolean used; + private final boolean nullHandled; + + public OpenApiParamUsageInfo(OpenApiParamInfo paramInfo, boolean used, boolean nullHandled) { + this.paramInfo = paramInfo; + this.used = used; + this.nullHandled = nullHandled; + } + + public OpenApiParamInfo getParamInfo() { + return paramInfo; + } + + public boolean isUsed() { + return used; + } + + public boolean isNullHandled() { + return nullHandled; + } + + @Override + public String toString() { + String realUse = used ? "In use" : "Not Used"; + return paramInfo + " - " + realUse; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof OpenApiParamUsageInfo)) { + return false; + } + OpenApiParamUsageInfo that = (OpenApiParamUsageInfo) o; + return Objects.equals(getParamInfo(), that.getParamInfo()); + } + + @Override + public int hashCode() { + return Objects.hashCode(getParamInfo()); + } +} diff --git a/cwms-data-api/src/test/java/helpers/OpenApiTestHelper.java b/cwms-data-api/src/test/java/helpers/OpenApiTestHelper.java new file mode 100644 index 000000000..919b2abaa --- /dev/null +++ b/cwms-data-api/src/test/java/helpers/OpenApiTestHelper.java @@ -0,0 +1,163 @@ +package helpers; + +import com.github.javaparser.JavaParser; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.symbolsolver.JavaSymbolSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.ClassLoaderTypeSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.CombinedTypeSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.JavaParserTypeSolver; +import com.github.javaparser.symbolsolver.resolution.typesolvers.ReflectionTypeSolver; +import io.javalin.plugin.openapi.annotations.OpenApi; +import io.javalin.plugin.openapi.annotations.OpenApiParam; +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import static java.util.stream.Collectors.toList; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class OpenApiTestHelper { + + + public static List findByName(Class klass, String name) { + Method[] methods = klass.getMethods(); + return Stream.of(methods).filter(m -> m.getName().equals(name)).collect(toList()); + } + + public static Method findOneByName(Class klass, String name) { + List methods = findByName(klass, name); + if (methods == null || methods.isEmpty()) { + throw new RuntimeException("Did not find method with name " + name); + } + if (methods.size() > 1) { + throw new RuntimeException("Multiple methods with name " + name); + } + return methods.get(0); + } + + public static OpenApiDocInfo readDocParams(Method m) { + OpenApi oa = m.getAnnotation(OpenApi.class); + if (oa == null || oa.ignore()) { + return new OpenApiDocInfo(m, true); + } + OpenApiDocInfo info = new OpenApiDocInfo(m, false); + for (OpenApiParam p : oa.queryParams()) { + if (p != null && !p.name().trim().isEmpty()) { + OpenApiParamInfo paramObj = new OpenApiParamInfo(p.name(), p.required(), p.type()); + info.getQueryParameters().add(paramObj); + } + } + for (OpenApiParam p : oa.pathParams()) { + if (p != null && !p.name().trim().isEmpty()) { + OpenApiParamInfo paramObj = new OpenApiParamInfo(p.name(), p.required(), p.type()); + info.getPathParameters().add(paramObj); + } + } + return info; + } + + private static String getFileNameWithoutExtension(Path path) { + String temp = path.toString().replace("\\", "/"); + String fileName = temp.replace("cwms-data-api/src/main/java/", "") + .replace("src/main/java/", "") + .replace("/", "."); + int dotIndex = fileName.lastIndexOf('.'); + return (dotIndex > 0) ? fileName.substring(0, dotIndex) : fileName; + } + + private static Class getClassFromName(String name, List classesNotFound) + { + Class clazz = null; + try { + clazz = Class.forName(name); + } catch (ClassNotFoundException ex) { + classesNotFound.add(name); + } + return clazz; + } + + public static List> findClassesOfType(Class type) { + return findClassesOfType(type, "cwms.cda.api"); + } + + public static List> findClassesOfType(Class type, String packageName) { + // Convert package name to path + String packagePath = packageName.replace('.', '/'); + + Path srcPath = getPackagePath(packagePath); + + List classesNotFound = new ArrayList<>(); + List temp; + + try { + temp = Files.walk(srcPath) + .filter(Files::isRegularFile) + .map(OpenApiTestHelper::getFileNameWithoutExtension) + .map(name -> getClassFromName(name, classesNotFound)) + .filter(clazz -> clazz != null && type.isAssignableFrom(clazz)) + .filter(clazz -> !Modifier.isAbstract(clazz.getModifiers())) + .collect(toList()); + } catch (IOException e) { + throw new RuntimeException("Error scanning for handler classes", e); + } + + assertTrue(classesNotFound.isEmpty(), "Unable to find classes for " + String.join(", ", classesNotFound)); + List> output = new ArrayList<>(); + for (Class clazz : temp) { + output.add((Class)clazz); + } + return output; + } + + private static Path getSrcRootPath() { + Path srcPath = Paths.get("cwms-data-api/src/main/java"); + + if (!Files.exists(srcPath)) { + // Try alternative path + srcPath = Paths.get("src/main/java"); + } + + return srcPath; + } + + private static Path getPackagePath(String packagePath) { + // Assuming the test is run from project root, adjust path as needed + return getSrcRootPath().resolve(packagePath); + } + + public static OpenApiDocTestInfo readOpenApiDocs(Class baseClass, Class primaryClass) { + + List infoObjs = Arrays.stream(baseClass.getDeclaredMethods()) + .map(method -> findOneByName(primaryClass, method.getName())) + .map(OpenApiTestHelper::readDocParams) + .collect(toList()); + return new OpenApiDocTestInfo(primaryClass, infoObjs); + } + + public static CompilationUnit readCompilationUnit(Class clazz) throws IOException { + String fullyQualifiedName = clazz.getName().replace(".", "/") + ".java"; + Path path = getPackagePath(fullyQualifiedName); + assertTrue(Files.exists(path)); + ParserConfiguration config = buildParserConfig(); + JavaParser parser = new JavaParser(config); + return parser.parse(path) + .getResult() + .orElseThrow(() -> new RuntimeException("Failed to parse file")); + } + + private static ParserConfiguration buildParserConfig() { + CombinedTypeSolver combinedTypeSolver = new CombinedTypeSolver(new ReflectionTypeSolver(), + new ClassLoaderTypeSolver(OpenApiTestHelper.class.getClassLoader()), + new JavaParserTypeSolver(getSrcRootPath())); + + return new ParserConfiguration() + .setSymbolResolver(new JavaSymbolSolver(combinedTypeSolver)); + } +} diff --git a/cwms-data-api/src/test/java/helpers/OpenApiTestHelperTest.java b/cwms-data-api/src/test/java/helpers/OpenApiTestHelperTest.java new file mode 100644 index 000000000..51cf1f3f9 --- /dev/null +++ b/cwms-data-api/src/test/java/helpers/OpenApiTestHelperTest.java @@ -0,0 +1,74 @@ +package helpers; + +import cwms.cda.api.Controllers; +import cwms.cda.api.OfficeController; +import cwms.cda.api.TextTimeSeriesValueController; +import cwms.cda.api.auth.users.UsersController; +import cwms.cda.api.auth.users.roles.AddRoleController; +import cwms.cda.api.rating.RatingController; +import cwms.cda.api.watersupply.WaterUserDeleteController; +import io.javalin.apibuilder.CrudHandler; +import io.javalin.http.Handler; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class OpenApiTestHelperTest { + + @Test + void test_helper_on_office() { + List methods = OpenApiTestHelper.findByName(OfficeController.class, "getAll"); + assertEquals(1, methods.size()); + + Method m = OpenApiTestHelper.findOneByName(OfficeController.class, "getAll"); + + assertNotNull(m); + OpenApiDocInfo info = OpenApiTestHelper.readDocParams(m); + assertNotNull(info); + assertEquals(2, info.getQueryParameters().size()); + assertTrue(info.getQueryParameters().contains(new OpenApiParamInfo(Controllers.FORMAT, false, String.class))); + assertTrue(info.getQueryParameters().contains(new OpenApiParamInfo(Controllers.HAS_DATA, false, String.class))); + + assertEquals(0, info.getPathParameters().size()); + + } + + @Test + void test_helper_on_office_bad_name() { + try{ + OpenApiTestHelper.findOneByName(OfficeController.class, "bad"); + fail("Should have thrown exception"); + } catch(RuntimeException ex){ + assertEquals("Did not find method with name bad", ex.getMessage()); + } + } + + @Test + void test_helper_for_interface() { + OpenApiDocTestInfo crudDocInfo = OpenApiTestHelper.readOpenApiDocs(CrudHandler.class, OfficeController.class); + assertEquals(5, crudDocInfo.getMethodDocs().size()); + + OpenApiDocTestInfo handlerDocInfo = OpenApiTestHelper.readOpenApiDocs(Handler.class, TextTimeSeriesValueController.class); + assertEquals(1, handlerDocInfo.getMethodDocs().size()); + } + + @Test + void test_find_handlers() { + List> crudHandlers = OpenApiTestHelper.findClassesOfType(CrudHandler.class); + assertTrue(crudHandlers.contains(OfficeController.class)); // CrudHandler + assertTrue(crudHandlers.contains(RatingController.class)); // CrudHandler in sub-package + assertTrue(crudHandlers.contains(UsersController.class)); // CrudHandler in sub-sub-package + + List> handlers = OpenApiTestHelper.findClassesOfType(Handler.class); + assertTrue(handlers.contains(TextTimeSeriesValueController.class)); // Handler + assertTrue(handlers.contains(WaterUserDeleteController.class)); // Handler in sub-package + assertTrue(handlers.contains(AddRoleController.class)); // Handler in sub-sub-package + + // Using assertAll allows us to test all cases and provide feedback for all cases instead of failing on the first failure. + assertAll(crudHandlers.stream().map(handler -> () -> assertFalse(handler.getPackage().getName().startsWith("io.javalin")))); + assertAll(handlers.stream().map(handler -> () -> assertFalse(handler.getPackage().getName().startsWith("io.javalin")))); + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0f5ac30cd..3176bb2e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ google-auto-service = "1.0-rc6" freemarker = "2.3.32" auto-service = "1.1.1" openapi-validation = "2.44.9" +javaparser = "3.26.2" #Overrides classgraph = { strictly = '4.8.176' } @@ -113,6 +114,8 @@ rest-assured = { module = "io.rest-assured:rest-assured", version.ref = "rest-as hamcrest-all = { module = "org.hamcrest:hamcrest-all", version.ref = "hamcrest" } apache-freemarker = { module = "org.freemarker:freemarker", version.ref = "freemarker" } apache-commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "apache-commons.csv" } +javaparser-core = { module = "com.github.javaparser:javaparser-core", version.ref = "javaparser" } +javaparser-symbol-solver-core = { module = "com.github.javaparser:javaparser-symbol-solver-core", version.ref = "javaparser"} # test runtime # tomcat @@ -140,3 +143,4 @@ testcontainers = [ "testcontainers-base", "testcontainers-database-commons", "te metrics = ["metrics-core", "metrics-servlets", "metrics-prometheus-client", "metrics-prometheus-servlets" ] jackson = ["jackson-core", "jackson-dataformat-csv", "jackson-dataformat-xml", "jackson-datatype-jsr310" ] overrides = ["io-github.classgraph", "io-swagger-parser"] +java-parser = ["javaparser-core", "javaparser-symbol-solver-core"] \ No newline at end of file