Skip to content

Commit

Permalink
OpenAPI 3.0 integration (#84)
Browse files Browse the repository at this point in the history
- Changes the path of the global time-shift endpoint
- Integrates and configures OpenAPI 3.0 dependency
- Annotates Management endpoints
- Adds new test cases

Resolves #83
{minor}
  • Loading branch information
nagyesta authored Mar 20, 2022
1 parent 9a7ea75 commit ea5cc19
Show file tree
Hide file tree
Showing 15 changed files with 340 additions and 12 deletions.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,24 @@ Lowkey Vault is far from supporting all Azure Key Vault features. The list suppo
- Recover deleted secret
- Purge deleted secret

### Management API

#### Functionality

- Create vault
- List vaults
- Delete vault
- List deleted vaults
- Recover deleted vault
- Purge vault
- Time-shift (simulate the passing of time)
- A single vault
- All vaults

#### Swagger

[https://localhost:8443/api/swagger-ui/index.html](https://localhost:8443/api/swagger-ui/index.html)

## Startup parameters

### Log requests
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ abortMission = "2.8.20"
checkstyle = "9.2.1"
jacoco = "0.8.2"
jackson = { strictly = "2.13.2" }
openApiUi = "1.6.6"

abortMissionPlugin = "2.2.6"
dockerPlugin = "0.32.0"
Expand All @@ -34,6 +35,7 @@ spring-boot-starter-tomcat = { module = "org.springframework.boot:spring-boot-st
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "springBoot" }
spring-boot-configuration-processor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "springBoot" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "springBoot" }
springdoc-openapi-ui = { module = "org.springdoc:springdoc-openapi-ui", version.ref = "openApiUi" }

tomcat-annotations-api = { module = "org.apache.tomcat:tomcat-annotations-api", version.ref = "tomcat" }
tomcat-jsp-api = { module = "org.apache.tomcat:tomcat-jsp-api", version.ref = "tomcat" }
Expand Down
1 change: 1 addition & 0 deletions lowkey-vault-app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies {
implementation libs.bouncycastle.bcpkix
implementation libs.hibernate.validator
implementation libs.findbugs.jsr305
implementation libs.springdoc.openapi.ui
annotationProcessor libs.lombok
annotationProcessor libs.spring.boot.configuration.processor
testImplementation libs.spring.boot.starter.test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,9 @@ public ResponseEntity<ErrorModel> handleException(final Exception exception) {
}
return ResponseEntity.status(status).body(ErrorModel.fromException(exception));
}

@ExceptionHandler({IllegalArgumentException.class})
public ResponseEntity<ErrorModel> handleArgumentException(final Exception exception) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ErrorModel.fromException(exception));
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class CommonAuthHeaderFilter extends OncePerRequestFilter {
private static final String HTTPS = "https://";
private static final String BEARER_FAKE_TOKEN = "Bearer resource=\"%s\", authorization_uri=\"%s\"";
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
private final Set<String> skipUrisIfMatch = Set.of("/ping", "/management/**");
private final Set<String> skipUrisIfMatch = Set.of("/ping", "/management/**", "/api/**");

@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,31 @@

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;

import java.util.Optional;

import static com.github.nagyesta.lowkeyvault.openapi.Examples.ERROR_MESSAGE;
import static com.github.nagyesta.lowkeyvault.openapi.Examples.EXCEPTION;

@Getter
@AllArgsConstructor
@ToString
@EqualsAndHashCode
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorMessage {
@Schema(example = EXCEPTION, description = "The class of the exception caused.")
@JsonProperty("code")
private String code;
@Hidden
@JsonProperty("innererror")
private ErrorMessage innerError;
@Schema(example = ERROR_MESSAGE, description = "The human readable message of the exception.")
@JsonProperty("message")
private String message;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,39 @@
import com.github.nagyesta.lowkeyvault.model.json.util.EpochSecondsDeserializer;
import com.github.nagyesta.lowkeyvault.model.json.util.EpochSecondsSerializer;
import com.github.nagyesta.lowkeyvault.model.v7_2.common.constants.RecoveryLevel;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import javax.validation.constraints.NotNull;
import java.net.URI;
import java.time.OffsetDateTime;

import static com.github.nagyesta.lowkeyvault.openapi.Examples.*;

@Data
public class VaultModel {
@Schema(example = BASE_URI, description = "The base URI of the vault.")
@NotNull
@JsonProperty("baseUri")
private URI baseUri;
@Schema(example = CUSTOMIZED_RECOVERABLE_PURGEABLE,
description = "Recovery level of the vault. See: "
+ "https://docs.microsoft.com/en-us/rest/api/keyvault/secrets/set-secret/set-secret#deletionrecoverylevel")
@NotNull
@JsonProperty("recoveryLevel")
private RecoveryLevel recoveryLevel;
@Schema(example = FORTY_TWO, minimum = "7", maximum = "90", nullable = true,
description = "Defines how long the vault will be recoverable after deletion. Acceptable values depend on recovery level.")
@JsonProperty("recoverableDays")
private Integer recoverableDays;
@Schema(example = EPOCH_SECONDS_2022_01_02_AM_03H_04M_05S, nullable = true, implementation = Integer.class, minimum = ONE,
description = "UTC epoch seconds formatted date time when the vault was created. (Should be null when used for create).")
@JsonProperty("created")
@JsonSerialize(using = EpochSecondsSerializer.class)
@JsonDeserialize(using = EpochSecondsDeserializer.class)
private OffsetDateTime createdOn;
@Schema(example = EPOCH_SECONDS_2022_01_02_AM_03H_04M_05S, nullable = true, implementation = Integer.class, minimum = ONE,
description = "UTC epoch seconds formatted date time when the vault was deleted. (Should be null when used for create).")
@JsonProperty("deleted")
@JsonSerialize(using = EpochSecondsSerializer.class)
@JsonDeserialize(using = EpochSecondsDeserializer.class)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.github.nagyesta.lowkeyvault.openapi;

public final class Examples {
/**
* Example base URI of a vault.
*/
public static final String BASE_URI = "https://vault.localhost:8443";
/**
* The literal 1 as a String.
*/
public static final String ONE = "1";
/**
* The literal 42 as a String.
*/
public static final String FORTY_TWO = "42";
/**
* The value of {@link com.github.nagyesta.lowkeyvault.model.v7_2.common.constants.RecoveryLevel#CUSTOMIZED_RECOVERABLE_AND_PURGEABLE}.
*/
public static final String CUSTOMIZED_RECOVERABLE_PURGEABLE = "CustomizedRecoverable+Purgeable";
/**
* UTC Epoch seconds format of 2022-01-02 03:04:05 AM.
*/
public static final String EPOCH_SECONDS_2022_01_02_AM_03H_04M_05S = "1641092645";
/**
* Example value of an exception class.
*/
public static final String EXCEPTION = "java.lang.IllegalArgumentException";
/**
* Error message example.
*/
public static final String ERROR_MESSAGE = "BaseUri must be populated.";

private Examples() {
throw new IllegalCallerException("Utility cannot be instantiated.");
}
}
5 changes: 5 additions & 0 deletions lowkey-vault-app/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ server.ssl.key-store-password=changeit
server.error.include-binding-errors=always
server.error.include-message=always
#
springdoc.api-docs.path=/api/docs
springdoc.swagger-ui.path=/api/swagger-ui.html
springdoc.pathsToMatch=/management/**
springdoc.swagger-ui.operationsSorter=alpha
#
spring.jackson.generator.flush-passed-to-stream=true
#
logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ void testTimeShiftGlobalShouldCallServiceWhenCalled() {
//given

//when
final ResponseEntity<Void> actual = underTest.timeShift(NUMBER_OF_SECONDS_IN_10_MINUTES);
final ResponseEntity<Void> actual = underTest.timeShiftAll(NUMBER_OF_SECONDS_IN_10_MINUTES);

//then
Assertions.assertEquals(HttpStatus.NO_CONTENT, actual.getStatusCode());
Expand All @@ -226,7 +226,7 @@ void testTimeShiftSingleShouldCallServiceWhenCalled() {
when(vaultService.findByUriIncludeDeleted(eq(HTTPS_DEFAULT_LOWKEY_VAULT))).thenReturn(vaultFakeActive);

//when
final ResponseEntity<Void> actual = underTest.timeShift(HTTPS_DEFAULT_LOWKEY_VAULT, NUMBER_OF_SECONDS_IN_10_MINUTES);
final ResponseEntity<Void> actual = underTest.timeShiftSingle(HTTPS_DEFAULT_LOWKEY_VAULT, NUMBER_OF_SECONDS_IN_10_MINUTES);

//then
Assertions.assertEquals(HttpStatus.NO_CONTENT, actual.getStatusCode());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,27 @@ void testErrorHandlerConvertsExceptionWhenCaught(final Exception exception, fina
}
}

@Test
void testErrorHandlerConvertsIllegalArgumentExceptionWhenCaught() {
//given
final HttpStatus status = HttpStatus.BAD_REQUEST;
final String message = "Message";
final Exception exception = new IllegalArgumentException(message);

//when
final ResponseEntity<ErrorModel> actual = underTest.handleArgumentException(exception);

//then
Assertions.assertEquals(status, actual.getStatusCode());
final ErrorModel actualBody = actual.getBody();
Assertions.assertNotNull(actualBody);
Assertions.assertNotNull(actualBody.getError());
Assertions.assertEquals(message, actualBody.getError().getMessage());
Assertions.assertEquals(exception.getClass().getName(), actualBody.getError().getCode());
final ErrorMessage actualInnerError = actualBody.getError().getInnerError();
Assertions.assertNull(actualInnerError);
}

@ParameterizedTest
@MethodSource("nullProvider")
void testConstructorShouldThrowExceptionWhenCalledWithNull(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.github.nagyesta.lowkeyvault.openapi;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

class ExamplesTest {

@Test
void testConstructorShouldThrowExceptionWhenCalled() throws NoSuchMethodException {
//given
final Constructor<Examples> constructor = Examples.class.getDeclaredConstructor();
constructor.setAccessible(true);

//when
Assertions.assertThrows(InvocationTargetException.class, constructor::newInstance);

//then + exception
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public final class LowkeyVaultManagementClientImpl implements LowkeyVaultManagem
private static final String MANAGEMENT_VAULT_RECOVERY_PATH = MANAGEMENT_VAULT_PATH + "/recover";
private static final String MANAGEMENT_VAULT_PURGE_PATH = MANAGEMENT_VAULT_PATH + "/purge";
private static final String MANAGEMENT_VAULT_TIME_PATH = MANAGEMENT_VAULT_PATH + "/time";
private static final String MANAGEMENT_VAULT_TIME_ALL_PATH = MANAGEMENT_VAULT_TIME_PATH + "/all";
private static final String BASE_URI_QUERY_PARAM = "baseUri";
private static final String SECONDS_QUERY_PARAM = "seconds";
private final String vaultUrl;
Expand Down Expand Up @@ -119,8 +120,10 @@ public boolean purge(@NonNull final URI baseUri) {
public void timeShift(@NonNull final TimeShiftContext context) {
final Map<String, String> parameters = new TreeMap<>();
parameters.put(SECONDS_QUERY_PARAM, Integer.toString(context.getSeconds()));
Optional.ofNullable(context.getVaultBaseUri()).ifPresent(uri -> parameters.put(BASE_URI_QUERY_PARAM, uri.toString()));
final URI uri = UriUtil.uriBuilderForPath(vaultUrl, MANAGEMENT_VAULT_TIME_PATH, parameters);
final Optional<URI> optionalURI = Optional.ofNullable(context.getVaultBaseUri());
optionalURI.ifPresent(uri -> parameters.put(BASE_URI_QUERY_PARAM, uri.toString()));
final String path = optionalURI.map(u -> MANAGEMENT_VAULT_TIME_PATH).orElse(MANAGEMENT_VAULT_TIME_ALL_PATH);
final URI uri = UriUtil.uriBuilderForPath(vaultUrl, path, parameters);
final HttpRequest request = new HttpRequest(HttpMethod.PUT, uri.toString())
.setHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON);
sendRaw(request);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ void testTimeShiftShouldSucceedWhenCalledWithOnlyTime() {
//then
verify(httpClient, atMostOnce()).send(any());
final HttpRequest request = httpRequestArgumentCaptor.getValue();
Assertions.assertEquals("/management/vault/time", request.getUrl().getPath());
Assertions.assertEquals("/management/vault/time/all", request.getUrl().getPath());
Assertions.assertEquals("seconds=86400", request.getUrl().getQuery());
Assertions.assertEquals(HttpMethod.PUT, request.getHttpMethod());
Assertions.assertEquals(APPLICATION_JSON, request.getHeaders().getValue(HttpHeaders.CONTENT_TYPE));
Expand Down

0 comments on commit ea5cc19

Please sign in to comment.