From 34d6dc50b5e876c01400cd82bd653fb537e9f537 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 3 Nov 2021 15:03:56 +0200 Subject: [PATCH] Swagger improvements --- .../server/config/SwaggerConfiguration.java | 132 +++++++++++++----- ...ThingsboardCredentialsExpiredResponse.java | 4 + .../exception/ThingsboardErrorResponse.java | 20 +++ pom.xml | 2 +- 4 files changed, 124 insertions(+), 34 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java index ba3771616a..25cc862924 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -15,25 +15,30 @@ */ package org.thingsboard.server.config; -import com.fasterxml.classmate.ResolvedType; import com.fasterxml.classmate.TypeResolver; -import com.fasterxml.jackson.databind.JsonNode; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.exception.ThingsboardCredentialsExpiredResponse; import org.thingsboard.server.exception.ThingsboardErrorResponse; import org.thingsboard.server.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.auth.rest.LoginResponse; import springfox.documentation.builders.ApiInfoBuilder; +import springfox.documentation.builders.ExampleBuilder; import springfox.documentation.builders.OperationBuilder; import springfox.documentation.builders.RepresentationBuilder; import springfox.documentation.builders.RequestParameterBuilder; import springfox.documentation.builders.ResponseBuilder; +import springfox.documentation.schema.Example; import springfox.documentation.service.ApiDescription; import springfox.documentation.service.ApiInfo; import springfox.documentation.service.ApiListing; @@ -74,6 +79,7 @@ import static java.util.function.Predicate.not; import static springfox.documentation.builders.PathSelectors.any; import static springfox.documentation.builders.PathSelectors.regex; +@Slf4j @Configuration public class SwaggerConfiguration { @@ -100,44 +106,30 @@ public class SwaggerConfiguration { @Value("${swagger.version}") private String version; - @Autowired - private CachingOperationNameGenerator operationNames; - @Bean public Docket thingsboardApi() { TypeResolver typeResolver = new TypeResolver(); - final ResolvedType jsonNodeType = - typeResolver.resolve( - JsonNode.class); - final ResolvedType stringType = - typeResolver.resolve( - String.class); - return new Docket(DocumentationType.OAS_30) .groupName("thingsboard") .apiInfo(apiInfo()) .additionalModels( typeResolver.resolve(ThingsboardErrorResponse.class), + typeResolver.resolve(ThingsboardCredentialsExpiredResponse.class), typeResolver.resolve(LoginRequest.class), typeResolver.resolve(LoginResponse.class) ) - /* .alternateTypeRules( - new AlternateTypeRule( - jsonNodeType, - stringType))*/ .select() .paths(apiPaths()) .paths(any()) .build() .globalResponses(HttpMethod.GET, - List.of( - new ResponseBuilder() - .code("401") - .description("Unauthorized") - .representation(MediaType.APPLICATION_JSON) - .apply(classRepresentation(ThingsboardErrorResponse.class, true)) - .build() - ) + defaultErrorResponses(false) + ) + .globalResponses(HttpMethod.POST, + defaultErrorResponses(true) + ) + .globalResponses(HttpMethod.DELETE, + defaultErrorResponses(false) ) .securitySchemes(newArrayList(httpLogin())) .securityContexts(newArrayList(securityContext())) @@ -146,15 +138,15 @@ public class SwaggerConfiguration { @Bean @Order(SwaggerPluginSupport.SWAGGER_PLUGIN_ORDER) - ApiListingScannerPlugin loginEndpointListingScanner() { + ApiListingScannerPlugin loginEndpointListingScanner(final CachingOperationNameGenerator operationNames) { return new ApiListingScannerPlugin() { @Override public List apply(DocumentationContext context) { - return List.of(loginEndpointApiDescription()); + return List.of(loginEndpointApiDescription(operationNames)); } @Override - public boolean supports(DocumentationType delimiter) { + public boolean supports(@NotNull DocumentationType delimiter) { return DocumentationType.SWAGGER_2.equals(delimiter) || DocumentationType.OAS_30.equals(delimiter); } }; @@ -176,7 +168,7 @@ public class SwaggerConfiguration { } @Override - public boolean supports(DocumentationType delimiter) { + public boolean supports(@NotNull DocumentationType delimiter) { return DocumentationType.SWAGGER_2.equals(delimiter) || DocumentationType.OAS_30.equals(delimiter); } }; @@ -199,6 +191,7 @@ public class SwaggerConfiguration { .showCommonExtensions(false) .supportedSubmitMethods(UiConfiguration.Constants.DEFAULT_SUBMIT_METHODS) .validatorUrl(null) + .persistAuthorization(true) .syntaxHighlightActivate(true) .syntaxHighlightTheme("agate") .build(); @@ -248,7 +241,7 @@ public class SwaggerConfiguration { .build(); } - private ApiDescription loginEndpointApiDescription() { + private ApiDescription loginEndpointApiDescription(final CachingOperationNameGenerator operationNames) { return new ApiDescription(null, "/api/auth/login", "Login method to get user JWT token data", "Login endpoint", Collections.singletonList( new OperationBuilder(operationNames) .summary("Login method to get user JWT token data") @@ -257,7 +250,8 @@ public class SwaggerConfiguration { .position(0) .codegenMethodNameStem("loginPost") .method(HttpMethod.POST) - .notes("Login method to get user JWT token data.\n\nValue of the response **token** field can be used as JWT token value for authorization.") + .notes("Login method used to authenticate user and get JWT token data.\n\nValue of the response **token** " + + "field can be used as **X-Authorization** header value:\n\n`X-Authorization: Bearer $JWT_TOKEN_VALUE`.") .requestParameters( List.of( new RequestParameterBuilder() @@ -278,7 +272,8 @@ public class SwaggerConfiguration { } private Collection loginResponses() { - return List.of( + List responses = new ArrayList<>(); + responses.add( new ResponseBuilder() .code("200") .description("OK") @@ -286,11 +281,82 @@ public class SwaggerConfiguration { .apply(classRepresentation(LoginResponse.class, true)). build() ); + responses.addAll(loginErrorResponses()); + return responses; } /** Helper methods **/ - private Consumer classRepresentation(Class clazz, boolean isResponse) { + private List defaultErrorResponses(boolean isPost) { + return List.of( + errorResponse("400", "Bad Request", + ThingsboardErrorResponse.of(isPost ? "Invalid request body" : "Invalid UUID string: 123", ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST)), + errorResponse("401", "Unauthorized", + ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), + errorResponse("403", "Forbidden", + ThingsboardErrorResponse.of("You don't have permission to perform this operation!", + ThingsboardErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN)), + errorResponse("404", "Not Found", + ThingsboardErrorResponse.of("Requested item wasn't found!", ThingsboardErrorCode.ITEM_NOT_FOUND, HttpStatus.NOT_FOUND)), + errorResponse("429", "Too Many Requests", + ThingsboardErrorResponse.of("Too many requests for current tenant!", + ThingsboardErrorCode.TOO_MANY_REQUESTS, HttpStatus.TOO_MANY_REQUESTS)) + ); + } + + private List loginErrorResponses() { + return List.of( + errorResponse("401", "Unauthorized", + List.of( + errorExample("bad-credentials", "Bad credentials", + ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), + errorExample("token-expired", "JWT token expired", + ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED)), + errorExample("account-disabled", "Disabled account", + ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), + errorExample("account-locked", "Locked account", + ThingsboardErrorResponse.of("User account is locked due to security policy", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), + errorExample("authentication-failed", "General authentication error", + ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)) + ) + ), + errorResponse("401 ", "Unauthorized (**Expired credentials**)", + List.of( + errorExample("credentials-expired", "Expired credentials", + ThingsboardCredentialsExpiredResponse.of("User password expired!", RandomStringUtils.randomAlphanumeric(30))) + ), ThingsboardCredentialsExpiredResponse.class + ) + ); + } + + private Response errorResponse(String code, String description, ThingsboardErrorResponse example) { + return errorResponse(code, description, List.of(errorExample("error-code-" + code, description, example))); + } + + private Response errorResponse(String code, String description, List examples) { + return errorResponse(code, description, examples, ThingsboardErrorResponse.class); + } + + private Response errorResponse(String code, String description, List examples, + Class errorResponseClass) { + return new ResponseBuilder() + .code(code) + .description(description) + .examples(examples) + .representation(MediaType.APPLICATION_JSON) + .apply(classRepresentation(errorResponseClass, true)) + .build(); + } + + private Example errorExample(String id, String summary, ThingsboardErrorResponse example) { + return new ExampleBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .summary(summary) + .id(id) + .value(example).build(); + } + + private Consumer classRepresentation(Class clazz, boolean isResponse) { return r -> r.model( m -> m.referenceModel(ref -> diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java index 1d2b78fae4..9dc95a3a05 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardCredentialsExpiredResponse.java @@ -15,9 +15,12 @@ */ package org.thingsboard.server.exception; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; import org.springframework.http.HttpStatus; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +@ApiModel public class ThingsboardCredentialsExpiredResponse extends ThingsboardErrorResponse { private final String resetToken; @@ -31,6 +34,7 @@ public class ThingsboardCredentialsExpiredResponse extends ThingsboardErrorRespo return new ThingsboardCredentialsExpiredResponse(message, resetToken); } + @ApiModelProperty(position = 5, value = "Password reset token", readOnly = true) public String getResetToken() { return resetToken; } diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java index 13d7b1e4a8..05df631f2c 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponse.java @@ -15,11 +15,14 @@ */ package org.thingsboard.server.exception; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; import org.springframework.http.HttpStatus; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import java.util.Date; +@ApiModel public class ThingsboardErrorResponse { // HTTP Response Status Code private final HttpStatus status; @@ -43,18 +46,35 @@ public class ThingsboardErrorResponse { return new ThingsboardErrorResponse(message, errorCode, status); } + @ApiModelProperty(position = 1, value = "HTTP Response Status Code", example = "401", readOnly = true) public Integer getStatus() { return status.value(); } + @ApiModelProperty(position = 2, value = "Error message", example = "Authentication failed", readOnly = true) public String getMessage() { return message; } + @ApiModelProperty(position = 3, value = "Platform error code:" + + "\n* `2` - General error (HTTP: 500 - Internal Server Error)" + + "\n\n* `10` - Authentication failed (HTTP: 401 - Unauthorized)" + + "\n\n* `11` - JWT token expired (HTTP: 401 - Unauthorized)" + + "\n\n* `15` - Credentials expired (HTTP: 401 - Unauthorized)" + + "\n\n* `20` - Permission denied (HTTP: 403 - Forbidden)" + + "\n\n* `30` - Invalid arguments (HTTP: 400 - Bad Request)" + + "\n\n* `31` - Bad request params (HTTP: 400 - Bad Request)" + + "\n\n* `32` - Item not found (HTTP: 404 - Not Found)" + + "\n\n* `33` - Too many requests (HTTP: 429 - Too Many Requests)" + + "\n\n* `34` - Too many updates (Too many updates over Websocket session)" + + "\n\n* `40` - Subscription violation (HTTP: 403 - Forbidden)", + example = "10", dataType = "integer", + readOnly = true) public ThingsboardErrorCode getErrorCode() { return errorCode; } + @ApiModelProperty(position = 4, value = "Timestamp", readOnly = true) public Date getTimestamp() { return timestamp; } diff --git a/pom.xml b/pom.xml index cf419aa9b4..ba162740c6 100755 --- a/pom.xml +++ b/pom.xml @@ -83,7 +83,7 @@ 4.8.0 2.19.1 3.0.2 - 3.0.1 + 3.0.2 1.6.3 0.7 1.15.0