From 955345e4baa73ea77d40b58b6f21bb2681fe2e5f Mon Sep 17 00:00:00 2001 From: Kamal Mohammed Date: Wed, 26 Feb 2025 18:38:08 -0700 Subject: [PATCH] GRAD2-3335 - Add Metadata Endpoint to All GRAD APIs (#384) * GRAD2-3335 - Add Metadata Endpoint to All GRAD APIs * GRAD2-3335 - Add Metadata Endpoint to All GRAD APIs * Update APIMetadataController.java --- .../config/WebSecurityConfiguration.java | 3 +- .../controller/APIMetadataController.java | 204 ++++++++++++++++++ 2 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 api/src/main/java/ca/bc/gov/educ/api/dataconversion/controller/APIMetadataController.java diff --git a/api/src/main/java/ca/bc/gov/educ/api/dataconversion/config/WebSecurityConfiguration.java b/api/src/main/java/ca/bc/gov/educ/api/dataconversion/config/WebSecurityConfiguration.java index 283e9824..6cd2823c 100644 --- a/api/src/main/java/ca/bc/gov/educ/api/dataconversion/config/WebSecurityConfiguration.java +++ b/api/src/main/java/ca/bc/gov/educ/api/dataconversion/config/WebSecurityConfiguration.java @@ -27,7 +27,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/api-docs/**", "/actuator/health", "/actuator/prometheus", - "/health") + "/health", + "/api/v1/metadata") .permitAll() .anyRequest().authenticated() ) diff --git a/api/src/main/java/ca/bc/gov/educ/api/dataconversion/controller/APIMetadataController.java b/api/src/main/java/ca/bc/gov/educ/api/dataconversion/controller/APIMetadataController.java new file mode 100644 index 00000000..b73f00b9 --- /dev/null +++ b/api/src/main/java/ca/bc/gov/educ/api/dataconversion/controller/APIMetadataController.java @@ -0,0 +1,204 @@ +package ca.bc.gov.educ.api.dataconversion.controller; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.With; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.io.ClassPathResource; +import org.springframework.lang.Nullable; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.*; + +import static java.util.Optional.ofNullable; + +@RestController +@RequestMapping("/api/v1") +@OpenAPIDefinition( + info = @Info(title = "API for Metadata", description = "API for Metadata", version = "1"), + security = {@SecurityRequirement(name = "OAUTH2", + scopes = {})}) +class APIMetadataController { + private final ApplicationContext context; + + @Autowired + public APIMetadataController(ApplicationContext context) { + this.context = context; + } + + @GetMapping("/metadata") + @Operation(summary = "API Metadata", description = "API Metadata", tags = {"Metadata"}) + @ApiResponses(value = {@ApiResponse(responseCode = "200", description = "OK")}) + String generateMetadata() { + final var controllers = new ArrayList(); + for (String controllerName : context.getBeanNamesForAnnotation(RestController.class)) { + if (StringUtils.equalsAnyIgnoreCase(controllerName, "OpenApiResource", + "SwaggerConfigResource", this.getClass().getSimpleName())) + continue; + final var controllerBean = context.getBean(controllerName); + final var baseApiPath = getApiPath( + AnnotationUtils.findAnnotation(controllerBean.getClass(), RequestMapping.class)); + final var controllerSecurityInfo = new ControllerInfo(StringUtils.capitalize(controllerName), new ArrayList<>()); + for (Method method : controllerBean.getClass().getMethods()) { + getMethodInfo(method) + .map(m -> m.withPrefixedApiPath(baseApiPath)) + .ifPresent(m -> controllerSecurityInfo.methods().add(m)); + } + controllers.add(controllerSecurityInfo); + } + String htmlTemplate = """ + + + + + + + + {EndpointDetailsHTML} + {DownstreamEndpointsHTML} + + + """; + return htmlTemplate.replace("{EndpointDetailsHTML}", getEndpointDetailsHTML(controllers)) + .replace("{DownstreamEndpointsHTML}", getDownstreamEndpointsHTML()); + } + + @With + private record ControllerInfo(String name, List methods) { + } + + @With + private record MethodInfo(String httpMethod, String apiPath, String security, String functionName) { + public MethodInfo withPrefixedApiPath(String prefixedApiPath) { + return withApiPath(prefixedApiPath + this.apiPath); + } + } + + private static Optional getMethodInfo(Method method) { + return Optional.ofNullable(AnnotationUtils.findAnnotation(method, GetMapping.class)) + .or(() -> ofNullable(AnnotationUtils.findAnnotation(method, PostMapping.class))) + .or(() -> ofNullable(AnnotationUtils.findAnnotation(method, DeleteMapping.class))) + .or(() -> ofNullable(AnnotationUtils.findAnnotation(method, PutMapping.class))) + .map(annotation -> AnnotationUtils.getAnnotationAttributes(method, annotation)) + .map(attributes -> new MethodInfo( + attributes.annotationType() + .getSimpleName() + .replace("Mapping", "") + .toUpperCase(), + getApiPath(attributes.getStringArray("value")), + ofNullable(AnnotationUtils.findAnnotation(method, PreAuthorize.class)) + .map(p -> p.value().replace("hasAuthority('", "") + .replace("') and", "") + .replace("')", "") + .replace("SCOPE_", "") + ) + .orElse(""), + method.getName() + )); + } + + private static String getApiPath(@Nullable RequestMapping requestMapping) { + return ofNullable(requestMapping) + .map(RequestMapping::value) + .map(APIMetadataController::getApiPath) + .orElse(""); + } + + private static String getApiPath(@Nullable String... array) { + return ofNullable(array) + .map(arr -> arr.length > 0 ? arr[0] : null) + .orElse(""); + } + + private static String getEndpointDetailsHTML(List controllers) { + HashSet scopes = new HashSet<>(); + StringBuilder endpointDetailsHTML = new StringBuilder(); + endpointDetailsHTML.append("
"); + for (ControllerInfo controller : controllers) { + endpointDetailsHTML.append("

") + .append(controller.name()) + .append("

") + .append("
    ") + .append("
  • ") + .append("
    ") + .append("
    Endpoint
    ") + .append("
    Scopes
    ") + .append("
    Method
  • "); + for (MethodInfo method : controller.methods()) { + endpointDetailsHTML.append("
  • ") + .append("
    ") + .append(method.httpMethod()).append("
    ") + .append("
    ").append(method.apiPath()).append("
    ") + .append("
    ").append(method.security()).append("
    ") + .append("
    ").append(method.functionName()).append("()
    ") + .append("
  • "); + if (method.security().contains(" ")) { + scopes.addAll(Arrays.stream(method.security().split(" ")).toList()); + } else + scopes.add(method.security()); + } + endpointDetailsHTML.append("
"); + } + endpointDetailsHTML.append("

All Scopes

"); + endpointDetailsHTML.append("
    "); + + for (String scope : scopes.stream().sorted().toList()) { + endpointDetailsHTML.append("
  • ").append("
    ") + .append(scope).append("
    ").append("
  • "); + } + endpointDetailsHTML.append("
"); + return endpointDetailsHTML.toString(); + } + + private String getDownstreamEndpointsHTML() { + StringBuilder downstreamEndpointsHTML = new StringBuilder(); + downstreamEndpointsHTML.append("

Downstream Api calls

"); + downstreamEndpointsHTML.append("
    "); + + YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean(); + yaml.setResources(new ClassPathResource("application.yaml")); + Properties properties = yaml.getObject(); + + assert properties != null; + for (Map.Entry entry : properties.entrySet()) { + if (entry.getKey().toString().startsWith("endpoint.")) { + downstreamEndpointsHTML.append("
  • ") + .append("
    ").append(entry.getValue().toString()).append("
    ") + .append("
  • "); + } + } + downstreamEndpointsHTML.append("
"); + return downstreamEndpointsHTML.toString(); + } +}