Skip to content

Commit

Permalink
Retry project documentation add/delete if conflict
Browse files Browse the repository at this point in the history
Closes gh-25
  • Loading branch information
mbhave committed Nov 16, 2024
1 parent 4889b3e commit 6dc789c
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 23 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-hateoas'
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.retry:spring-retry:2.0.10'
implementation 'com.azure.spring:spring-cloud-azure-starter-keyvault-secrets'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.2'
Expand Down
16 changes: 14 additions & 2 deletions src/main/java/io/spring/projectapi/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,20 @@
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.web.client.HttpClientErrorException;

@SpringBootApplication
@EnableConfigurationProperties(ApplicationProperties.class)
public class Application {

@Bean
public GithubOperations githubOperations(RestTemplateBuilder builder, ObjectMapper objectMapper,
ApplicationProperties properties) {
ApplicationProperties properties, RetryTemplate retryTemplate) {
Github github = properties.getGithub();
String accessToken = github.getAccesstoken();
String branch = github.getBranch();
return new GithubOperations(builder, objectMapper, accessToken, branch);
return new GithubOperations(builder, objectMapper, accessToken, branch, retryTemplate);
}

@Bean
Expand All @@ -49,6 +51,16 @@ public GithubQueries githubQueries(RestTemplateBuilder builder, ObjectMapper obj
return new GithubQueries(builder, objectMapper, accessToken, branch);
}

@Bean
public RetryTemplate retryTemplate() {
return RetryTemplate.builder().maxAttempts(10).exponentialBackoff(100, 2, 10000).retryOn((throwable) -> {
if (throwable instanceof HttpClientErrorException ex) {
return (ex.getStatusCode().value() == 409);
}
return false;
}).build();
}

public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2022-2023 the original author or authors.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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 io.spring.projectapi.github;

import org.springframework.web.client.HttpClientErrorException;

/**
* {@link GithubException} thrown when an update results in a conflict.
*
* @author Madhura Bhave
*/
public class ConflictingGithubContentException extends GithubException {

ConflictingGithubContentException(String projectSlug, String fileName) {
super("Conflicting update for slug '%s' and file %s".formatted(projectSlug, fileName));
}

static void throwIfConflict(HttpClientErrorException ex, String projectSlug, String fileName) {
if (ex.getStatusCode().value() == 409) {
throw new ConflictingGithubContentException(projectSlug, fileName);
}
}

}
63 changes: 43 additions & 20 deletions src/main/java/io/spring/projectapi/github/GithubOperations.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.StringUtils;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
Expand Down Expand Up @@ -84,8 +85,11 @@ public class GithubOperations {

private final String branch;

private final RetryTemplate retryTemplate;

public GithubOperations(RestTemplateBuilder restTemplateBuilder, ObjectMapper objectMapper, String token,
String branch) {
String branch, RetryTemplate retryTemplate) {
this.retryTemplate = retryTemplate;
this.restTemplate = restTemplateBuilder.rootUri(GITHUB_URI)
.defaultHeader("Authorization", "Bearer " + token)
.build();
Expand All @@ -102,17 +106,26 @@ private static int compare(ProjectDocumentation o1, ProjectDocumentation o2) {
}

public void addProjectDocumentation(String projectSlug, ProjectDocumentation documentation) {
ResponseEntity<Map<String, Object>> response = getFile(projectSlug, "documentation.json");
List<ProjectDocumentation> documentations = new ArrayList<>();
String sha = null;
if (response != null) {
String content = getFileContents(response);
sha = getFileSha(response);
documentations.addAll(convertToProjectDocumentation(content));
try {
this.retryTemplate.execute((context) -> {
ResponseEntity<Map<String, Object>> response = getFile(projectSlug, "documentation.json");
List<ProjectDocumentation> documentations = new ArrayList<>();
String sha = null;
if (response != null) {
String content = getFileContents(response);
sha = getFileSha(response);
documentations.addAll(convertToProjectDocumentation(content));
}
documentations.add(documentation);
List<ProjectDocumentation> updatedDocumentation = computeCurrentRelease(documentations);
updateProjectDocumentation(projectSlug, updatedDocumentation, sha);
return null;
});
}
documentations.add(documentation);
List<ProjectDocumentation> updatedDocumentation = computeCurrentRelease(documentations);
updateProjectDocumentation(projectSlug, updatedDocumentation, sha);
catch (HttpClientErrorException ex) {
ConflictingGithubContentException.throwIfConflict(ex, projectSlug, "documentation.json");
}

}

private List<ProjectDocumentation> convertToProjectDocumentation(String content) {
Expand Down Expand Up @@ -201,15 +214,25 @@ private static ProjectDocumentation updateCurrent(ProjectDocumentation documenta
}

public void deleteDocumentation(String projectSlug, String version) {
ResponseEntity<Map<String, Object>> response = getFile(projectSlug, "documentation.json");
NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "documentation.json");
String content = getFileContents(response);
String sha = getFileSha(response);
List<ProjectDocumentation> documentation = convertToProjectDocumentation(content);
NoSuchGithubProjectDocumentationFoundException.throwIfHasNotPresent(documentation, projectSlug, version);
documentation.removeIf((y) -> y.getVersion().equals(version));
List<ProjectDocumentation> documentations1 = computeCurrentRelease(documentation);
updateProjectDocumentation(projectSlug, documentations1, sha);
try {
this.retryTemplate.execute((context) -> {
ResponseEntity<Map<String, Object>> response = getFile(projectSlug, "documentation.json");
NoSuchGithubFileFoundException.throwWhenFileNotFound(response, projectSlug, "documentation.json");
String content = getFileContents(response);
String sha = getFileSha(response);
List<ProjectDocumentation> documentation = convertToProjectDocumentation(content);
NoSuchGithubProjectDocumentationFoundException.throwIfHasNotPresent(documentation, projectSlug,
version);
documentation.removeIf((y) -> y.getVersion().equals(version));
List<ProjectDocumentation> documentations1 = computeCurrentRelease(documentation);
updateProjectDocumentation(projectSlug, documentations1, sha);
return null;
});
}
catch (HttpClientErrorException ex) {
ConflictingGithubContentException.throwIfConflict(ex, projectSlug, "documentation.json");
}

}

private ResponseEntity<Map<String, Object>> getFile(String projectSlug, String fileName) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package io.spring.projectapi.web.error;

import io.spring.projectapi.github.ConflictingGithubContentException;
import io.spring.projectapi.github.NoSuchGithubProjectDocumentationFoundException;
import io.spring.projectapi.github.NoSuchGithubProjectException;

Expand All @@ -34,6 +35,8 @@ public class ExceptionAdvice {

private static final ResponseEntity<Object> NOT_FOUND = ResponseEntity.notFound().build();

private static final ResponseEntity<Object> CONFLICT = ResponseEntity.status(409).build();

@ExceptionHandler
private ResponseEntity<?> noSuchGithubProjectExceptionHandler(NoSuchGithubProjectException ex) {
return NOT_FOUND;
Expand All @@ -45,4 +48,9 @@ private ResponseEntity<?> noSuchGithubProjectDocumentationExceptionHandler(
return NOT_FOUND;
}

@ExceptionHandler
private ResponseEntity<?> conflictingGithubContentsExceptionHandler(ConflictingGithubContentException ex) {
return CONFLICT;
}

}
51 changes: 51 additions & 0 deletions src/test/java/io/spring/projectapi/ApplicationTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright 2022-2023 the original author or authors.
*
* 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
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* 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 io.spring.projectapi;

import org.junit.jupiter.api.Test;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.test.util.ReflectionTestUtils;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Integration tests.
*/
@SpringBootTest
class ApplicationTests {

@Autowired
private RetryTemplate retryTemplate;

@MockBean
private ProjectRepository projectRepository;

@Test
void retryTemplate() {
ExponentialBackOffPolicy backOffPolicy = (ExponentialBackOffPolicy) ReflectionTestUtils
.getField(this.retryTemplate, "backOffPolicy");
assertThat(ReflectionTestUtils.getField(backOffPolicy, "initialInterval")).isEqualTo(100L);
assertThat(ReflectionTestUtils.getField(backOffPolicy, "multiplier")).isEqualTo(2.0);
assertThat(ReflectionTestUtils.getField(backOffPolicy, "maxInterval")).isEqualTo(10000L);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.client.HttpClientErrorException;

import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath;
Expand All @@ -60,15 +62,27 @@ class GithubOperationsTests {

private static final String DOCUMENTATION_URI = "/project/test-project/documentation.json?ref=test";

private RetryTemplate retryTemplate;

@BeforeEach
void setup() {
this.customizer = new MockServerRestTemplateCustomizer();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new ParameterNamesModule(JsonCreator.Mode.PROPERTIES));
objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
objectMapper.registerModule(new JavaTimeModule());
this.retryTemplate = getRetryTemplate();
this.operations = new GithubOperations(new RestTemplateBuilder(this.customizer), objectMapper, "test-token",
"test");
"test", this.retryTemplate);
}

private static RetryTemplate getRetryTemplate() {
return RetryTemplate.builder().maxAttempts(2).retryOn((throwable) -> {
if (throwable instanceof HttpClientErrorException ex) {
return ex.getStatusCode().value() == 409;
}
return false;
}).build();
}

@Test
Expand All @@ -78,6 +92,28 @@ void addProjectDocumentationWhenProjectDoesNotExistThrowsException() throws Exce
.addProjectDocumentation("does-not-exist", getDocumentation("1.0", Status.GENERAL_AVAILABILITY)));
}

@Test
void addProjectDocumentationWhenConflictShouldRetry() throws Exception {
setupFile("project-documentation-response.json", DOCUMENTATION_URI);
this.customizer.getServer().expect(method(HttpMethod.PUT)).andRespond(withStatus(HttpStatus.CONFLICT));
setupFile("project-documentation-response.json", DOCUMENTATION_URI);
setupFileUpdate("project-documentation-updated-content.json", "Update documentation",
"2d2f875ca7d476d8b01bc1db07d29b5eba1d5120");
ProjectDocumentation documentation = getDocumentation("3.15.1", Status.GENERAL_AVAILABILITY);
this.operations.addProjectDocumentation("test-project", documentation);
}

@Test
void addProjectDocumentationWhenConflictShouldThrowWhenRetriesFail() throws Exception {
setupFile("project-documentation-response.json", DOCUMENTATION_URI);
this.customizer.getServer().expect(method(HttpMethod.PUT)).andRespond(withStatus(HttpStatus.CONFLICT));
setupFile("project-documentation-response.json", DOCUMENTATION_URI);
this.customizer.getServer().expect(method(HttpMethod.PUT)).andRespond(withStatus(HttpStatus.CONFLICT));
ProjectDocumentation documentation = getDocumentation("3.15.1", Status.GENERAL_AVAILABILITY);
assertThatExceptionOfType(ConflictingGithubContentException.class)
.isThrownBy(() -> this.operations.addProjectDocumentation("test-project", documentation));
}

@Test
void addProjectDocumentation() throws Exception {
setupFile("project-documentation-response.json", DOCUMENTATION_URI);
Expand Down

0 comments on commit 6dc789c

Please sign in to comment.