test
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class ApiPlatformRegistry {
// Ключ: "ClassName#methodName", например "PersonController#read"
// Значение: список аннотаций с этого метода
private static final Map<String, List<PlatformApiResponse>> REGISTRY = new ConcurrentHashMap<>();
public static void register(String key, List<PlatformApiResponse> responses) {
REGISTRY.put(key, responses);
}
public static Map<String, List<PlatformApiResponse>> getAll() {
return Collections.unmodifiableMap(REGISTRY);
}
}
// -----
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import io.quarkus.runtime.StartupEvent;
import java.lang.reflect.Method;
import java.util.Arrays;
@ApplicationScoped
public class ApiPlatformScanner {
// Сюда добавляете свои контроллеры
private static final Class<?>[] CONTROLLERS = {
// com.banank.platform.services.profile.controller.PersonController.class
};
public void onStart(@Observes StartupEvent event) {
for (Class<?> controller : CONTROLLERS) {
for (Method method : controller.getDeclaredMethods()) {
String key = controller.getSimpleName() + "#" + method.getName();
// Случай 1: несколько аннотаций → Quarkus оборачивает их в контейнер
PlatformApiResponses container = method.getAnnotation(PlatformApiResponses.class);
if (container != null) {
ApiPlatformRegistry.register(key, Arrays.asList(container.value()));
continue;
}
// Случай 2: одна аннотация без контейнера
PlatformApiResponse single = method.getAnnotation(PlatformApiResponse.class);
if (single != null) {
ApiPlatformRegistry.register(key, java.util.List.of(single));
}
}
}
}
}
//--
package com.banank.platform.services.profile.exception.utils;
import jakarta.ws.rs.container.ResourceInfo;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response;
import lombok.extern.slf4j.Slf4j;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
import java.lang.reflect.Method;
@Slf4j
public class GlobalExceptionHandler {
@Context
ResourceInfo resourceInfo;
@ServerExceptionMapper
public Response handleException(RuntimeException ex) {
Method method = resourceInfo.getResourceMethod();
if (method != null) {
ExceptionMapping mapping = findMappingFromAnnotations(method, ex.getClass());
if (mapping != null) {
return createResponse(mapping, ex);
}
Class<?> resourceClass = resourceInfo.getResourceClass();
mapping = findMappingFromAnnotations(resourceClass, ex.getClass());
if (mapping != null) {
return createResponse(mapping, ex);
}
}
log.error("Unexpected error: {}", ex.getMessage(), ex);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"))
.build();
}
private ExceptionMapping findMappingFromAnnotations(Method method, Class<? extends Exception> exceptionClass) {
PlatformApiResponses apiErrorResponses = method.getAnnotation(PlatformApiResponses.class);
if (apiErrorResponses != null) {
for (PlatformApiResponse apiErrorResponse : apiErrorResponses.value()) {
if (apiErrorResponse.exception().isAssignableFrom(exceptionClass)) {
return new ExceptionMapping(
Integer.parseInt(apiErrorResponse.responseCode()),
apiErrorResponse.responseCode(),
apiErrorResponse.description()
);
}
}
}
PlatformApiResponse apiErrorResponse = method.getAnnotation(PlatformApiResponse.class);
if (apiErrorResponse != null && apiErrorResponse.exception().isAssignableFrom(exceptionClass)) {
return new ExceptionMapping(
Integer.parseInt(apiErrorResponse.responseCode()),
apiErrorResponse.responseCode(),
apiErrorResponse.description()
);
}
return null;
}
private ExceptionMapping findMappingFromAnnotations(Class<?> clazz, Class<? extends Exception> exceptionClass) {
PlatformApiResponses apiErrorResponses = clazz.getAnnotation(PlatformApiResponses.class);
if (apiErrorResponses != null) {
for (PlatformApiResponse apiErrorResponse : apiErrorResponses.value()) {
if (apiErrorResponse.exception().isAssignableFrom(exceptionClass)) {
return new ExceptionMapping(
Integer.parseInt(apiErrorResponse.responseCode()),
apiErrorResponse.responseCode(),
apiErrorResponse.description()
);
}
}
}
PlatformApiResponse apiErrorResponse = clazz.getAnnotation(PlatformApiResponse.class);
if (apiErrorResponse != null && apiErrorResponse.exception().isAssignableFrom(exceptionClass)) {
return new ExceptionMapping(
Integer.parseInt(apiErrorResponse.responseCode()),
apiErrorResponse.responseCode(),
apiErrorResponse.description()
);
}
return null;
}
private Response createResponse(ExceptionMapping mapping, Exception ex) {
Response.Status status = Response.Status.fromStatusCode(mapping.status);
// Приоритет: 1) ex.getMessage(), 2) defaultMessage, 3) description
String message = determineMessage(ex, mapping);
if (status.getFamily() == Response.Status.Family.SERVER_ERROR) {
log.error("{}: {}", mapping.code, message, ex);
} else {
log.warn("{}: {}", mapping.code, message);
}
return Response.status(status)
.entity(new ErrorResponse(mapping.code, message))
.build();
}
private String determineMessage(Exception ex, ExceptionMapping mapping) {
// 1. Пытаемся взять сообщение из исключения
String exceptionMessage = ex.getMessage();
if (exceptionMessage != null && !exceptionMessage.isBlank()) {
return exceptionMessage;
}
// 3. Fallback на description
return mapping.description;
}
public record ErrorResponse(String code, String message) {}
private record ExceptionMapping(int status, String code, String description) {}
}
// -- package com.banank.platform.services.profile.exception.utils;
import org.eclipse.microprofile.openapi.OASFactory;
import org.eclipse.microprofile.openapi.OASFilter;
import org.eclipse.microprofile.openapi.models.Operation;
import org.eclipse.microprofile.openapi.models.media.Content;
import org.eclipse.microprofile.openapi.models.responses.APIResponse;
import org.eclipse.microprofile.openapi.models.responses.APIResponses;
import java.util.List;
import java.util.Map;
public class PlatformApiOASFilter implements OASFilter {
@Override
public Operation filterOperation(Operation operation) {
if (operation == null || operation.getOperationId() == null) {
return operation;
}
String operationId = operation.getOperationId();
for (Map.Entry<String, List<PlatformApiResponse>> entry : ApiPlatformRegistry.getAll().entrySet()) {
String[] parts = entry.getKey().split("#");
String className = parts[0];
String methodName = parts[1];
if (!operationId.contains(className) || !operationId.contains(methodName)) {
continue;
}
APIResponses responses = operation.getResponses();
if (responses == null) {
responses = OASFactory.createAPIResponses();
operation.setResponses(responses);
}
for (PlatformApiResponse error : entry.getValue()) {
APIResponse apiResponse = OASFactory.createAPIResponse()
.description(error.description());
if (error.content().length > 0) {
Content content = OASFactory.createContent();
for (var c : error.content()) {
content.addMediaType(c.mediaType(), OASFactory.createMediaType());
}
apiResponse.setContent(content);
}
responses.addAPIResponse(error.responseCode(), apiResponse);
}
}
return operation;
}
}
// --
/**
* Copyright (c) 2017 Contributors to the Eclipse Foundation
* Copyright 2017 SmartBear Software
* <p>
* 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
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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 com.banank.platform.services.profile.exception.utils;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.headers.Header;
import org.eclipse.microprofile.openapi.annotations.links.Link;
import org.eclipse.microprofile.openapi.annotations.media.Content;
/**
* The APIResponse annotation corresponds to the OpenAPI Response model object which describes a single response from an
* API Operation, including design-time, static links to operations based on the response.
* <p>
* When this annotation is applied to a Jakarta REST method the response is added to the responses defined in the
* corresponding OpenAPI operation. If the operation already has a response with the specified responseCode the
* annotation on the method is ignored.
*
* <pre>
* @APIResponse(responseCode = "200", description = "Calculate load size", content = {
* @Content(mediaType = "application/json", Schema = @Schema(type = "integer"))})
* @GET
* public getLuggageWeight(Flight id) {
* return getBagWeight(id) + getCargoWeight(id);
* }
* </pre>
* <p>
* When this annotation is applied to a Jakarta REST resource class, the response is added to the responses defined in
* all OpenAPI operations which correspond to a method on that class. If an operation already has a response with the
* specified responseCode the response is not added to that operation.
*
* <p>
* When this annotation is applied to an <code>ExceptionMapper</code> class or <code>toResponse</code> method, it allows
* developers to describe the API response that will be added to a generated OpenAPI operation based on a Jakarta REST
* method that declares an <code>Exception</code> of the type handled by the <code>ExceptionMapper</code>.
*
* <pre>
* @Provider
* public class NotFoundExceptionMapper implements ExceptionMapper<NotFoundException> {
* @Override
* @APIResponse(responseCode = "404", description = "Not Found")
* public Response toResponse(NotFoundException t) {
* return Response.status(404)
* .type(MediaType.TEXT_PLAIN)
* .entity("Not found")
* .build();
* }
* }
* </pre>
*
* @see <a href="https://spec.openapis.org/oas/v3.1.0.html#response-object">OpenAPI Specification Response Object</a>
*
**/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Repeatable(PlatformApiResponses.class)
public @interface PlatformApiResponse {
Class<? extends Exception> exception();
/**
* A short description of the response. It is a REQUIRED property unless this is only a reference to a response
* instance.
*
* @return description of the response.
**/
String description() default "";
/**
* The HTTP response code, or 'default', for the supplied response. May only have 1 default entry.
*
* @return HTTP response code for this response instance or default
**/
String responseCode() default "default";
/**
* An array of response headers. Allows additional information to be included with response.
* <p>
* RFC7230 states header names are case insensitive. If a response header is defined with the name "Content-Type",
* it SHALL be ignored.
*
* @return array of headers for this response instance
**/
Header[] headers() default {};
/**
* An array of operation links that can be followed from the response.
*
* @return array of operation links for this response instance
**/
Link[] links() default {};
/**
* An array containing descriptions of potential response payloads for different media types.
*
* @return content of this response instance
**/
Content[] content() default {};
/**
* The unique name to identify this response. Only REQUIRED when the response is defined within
* {@link org.eclipse.microprofile.openapi.annotations.Components}. The name will be used as the key to add this
* response to the 'responses' map for reuse.
*
* @return this response's name
**/
String name() default "";
/**
* Reference value to a Response object.
* <p>
* This property provides a reference to an object defined elsewhere. This property may be used with
* {@link #description()} but is mutually exclusive with all other properties. If properties other than
* {@code description} are defined in addition to the {@code ref} property then the result is undefined.
*
* @return reference to a response
**/
String ref() default "";
/**
* List of extensions to be added to the {@link org.eclipse.microprofile.openapi.models.responses.APIResponse
* APIResponse} model corresponding to the containing annotation.
*
* @return array of extensions
*
* @since 3.1
*/
Extension[] extensions() default {};
}
//--
package com.banank.platform.services.profile.exception.utils;
import java.lang.annotation.*;
// Контейнер для @Repeatable — без него несколько @ApiErrorResponse на метод не заработает
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface PlatformApiResponses {
PlatformApiResponse[] value();
}
// --
@Slf4j
@Path("/profile/person")
@RequireUserId
@Tags({
@Tag(name = "banank-identification"),
@Tag(name = "Profile Person", description = "Profile Person CRUD")
})
public class PersonResource {
@Inject
UserContext userContext;
@Inject
PersonService personService;
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Set profile person", description = "Receives person data and save it")
@Parameters({
@Parameter(name = "X-User-Id", description = "User ID required", in = ParameterIn.HEADER, required = true, schema = @Schema(type = SchemaType.STRING))
})
@APIResponses({
@APIResponse(responseCode = "204", description = "Person successfully created"),
@APIResponse(responseCode = "400", description = "Invalid person payload")
})
public Response update(@Valid CreatePersonRequestDto request) {
log.info("Received request: {}", request);
String userId = userContext.getUserId();
var person = new Person();
person.setName(request.name());
person.setLastName(request.lastName());
person.setDateOfBirth(request.dateOfBirth());
try {
personService.putIfKycNotPassed(userId, person);
} catch (IllegalStateException e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
}
return Response.ok().build();
}
@GET
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Get profile person", description = "Get profile person data")
@Parameters({
@Parameter(name = "X-User-Id", description = "User ID required", in = ParameterIn.HEADER, required = true, schema = @Schema(type = SchemaType.STRING))
})
/* @APIResponses({
@APIResponse(responseCode = "204", description = "Successfully initialized", content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = GetPersonResponseDto.class))),
@APIResponse(responseCode = "400", description = "Invalid request"),
})
*/
@PlatformApiResponse(
exception = PersonGetFailedException.class,
responseCode = "409",
description = "Person Read Failed"
)
public Response read() {
String userId = userContext.getUserId();
Optional<Person> maybePerson = personService.get(userId);
if (maybePerson.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND).build();
}
Person person = maybePerson.get();
GetPersonResponseDto response = new GetPersonResponseDto(person.getName(), person.getLastName(), person.getDateOfBirth());
return Response.ok().entity(response).build();
}
}
все ли тут верно если я хочу отображать PlatformApiResponse в swagger?