deletePet(Long id) {
+ log.info("Delete GitHub pet by ID: {}", id);
+ return evaluate(() -> github.remoteDeleteById(id));
+ }
+}
diff --git a/src/main/java/com/example/repository/LocalRepository.java b/src/main/java/com/example/repository/LocalRepository.java
new file mode 100644
index 0000000..8840596
--- /dev/null
+++ b/src/main/java/com/example/repository/LocalRepository.java
@@ -0,0 +1,82 @@
+package com.example.repository;
+
+import com.example.api.ApiError;
+import com.example.api.Pet;
+import com.example.api.RepositoryType;
+import com.example.config.PetStoreConfig;
+import com.leakyabstractions.result.api.Result;
+import com.leakyabstractions.result.core.Results;
+import org.slf4j.Logger;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import java.util.Map;
+
+import static com.example.api.RepositoryType.LOCAL;
+import static java.lang.Boolean.TRUE;
+import static org.slf4j.LoggerFactory.getLogger;
+
+/**
+ * Implements the local pet repository.
+ *
+ * Maintains an in-memory collection of pets that can be manipulated.
+ */
+@Service
+class LocalRepository implements PetRepository {
+
+ static final Logger log = getLogger(LocalRepository.class);
+
+ final Result extends Map, ApiError> pets;
+
+ public LocalRepository(PetStoreConfig config) {
+ pets = Results.success(config)
+ .filter(PetStoreConfig::isLocalEnabled, x -> ApiError.unavailable(LOCAL))
+ .mapSuccess(PetStoreConfig::getPetsAsMap);
+ }
+
+ @Override
+ public RepositoryType getType() {
+ return LOCAL;
+ }
+
+ @Override
+ public Result, ApiError> listPets() {
+ log.info("List Local pets");
+ return pets.mapSuccess(Map::values);
+ }
+
+ @Override
+ public Result createPet(Pet pet) {
+ log.info("Create Local pet: {}", pet);
+ return pets.flatMapSuccess(db -> {
+ final Long id = db.keySet().stream().mapToLong(Long.class::cast).max().orElse(0L) + 1;
+ pet.setId(id);
+ return Results.ofCallable(() -> db.put(id, pet) != null)
+ .map(x -> pet, ApiError::unexpected);
+ });
+ }
+
+ @Override
+ public Result updatePet(Pet pet) {
+ final Long id = pet.getId();
+ log.info("Update Local pet #{}: {}", id, pet);
+ return pets.flatMapSuccess(db -> Results.ofCallable(() -> db.replace(id, pet) != null)
+ .mapFailure(ApiError::unexpected)
+ .filter(TRUE::equals, x -> ApiError.notFound(id))
+ .mapSuccess(x -> pet));
+ }
+
+ @Override
+ public Result findPet(Long id) {
+ log.info("Find Local pet by ID: {}", id);
+ return pets.flatMapSuccess(db -> Results.ofNullable(db.get(id), () -> ApiError.notFound(id)));
+ }
+
+ @Override
+ public Result deletePet(Long id) {
+ log.info("Delete Local pet by ID: {}", id);
+ return pets.flatMapSuccess(db -> Results.ofCallable(() -> db.remove(id) != null)
+ .mapFailure(ApiError::unexpected)
+ .filter(TRUE::equals, x -> ApiError.notFound(id)));
+ }
+}
diff --git a/src/main/java/com/example/repository/LoopbackRepository.java b/src/main/java/com/example/repository/LoopbackRepository.java
new file mode 100644
index 0000000..9083acb
--- /dev/null
+++ b/src/main/java/com/example/repository/LoopbackRepository.java
@@ -0,0 +1,61 @@
+package com.example.repository;
+
+import com.example.api.ApiError;
+import com.example.api.Pet;
+import com.example.client.LoopbackClient;
+import com.leakyabstractions.result.api.Result;
+import org.slf4j.Logger;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+
+import static com.example.api.RepositoryType.LOOPBACK;
+import static org.slf4j.LoggerFactory.getLogger;
+
+/**
+ * Implements the loopback pet repository.
+ *
+ * Interacts with the local pet store via HTTP.
+ */
+@Service
+class LoopbackRepository extends RemoteRepository implements PetRepository {
+
+ static final Logger log = getLogger(LoopbackRepository.class);
+
+ final LoopbackClient loopback;
+
+ LoopbackRepository(LoopbackClient loopback) {
+ super(LOOPBACK);
+ this.loopback = loopback;
+ }
+
+ @Override
+ public Result createPet(Pet pet) {
+ log.info("Create Loopback pet: {}", pet);
+ return evaluate(() -> loopback.remoteCreate(pet));
+ }
+
+ @Override
+ public Result, ApiError> listPets() {
+ log.info("List Loopback pets");
+ return evaluate(loopback::remoteList);
+ }
+
+ @Override
+ public Result updatePet(Pet pet) {
+ log.info("Update Loopback pet: {}", pet);
+ return evaluate(() -> loopback.remoteUpdate(pet));
+ }
+
+ @Override
+ public Result findPet(Long id) {
+ log.info("Find Loopback pet by ID: {}", id);
+ return evaluate(() -> loopback.remoteFindById(id));
+ }
+
+ @Override
+ public Result deletePet(Long id) {
+ log.info("Delete Loopback pet by ID: {}", id);
+ return evaluate(() -> loopback.remoteDeleteById(id));
+ }
+}
diff --git a/src/main/java/com/example/repository/PetRepository.java b/src/main/java/com/example/repository/PetRepository.java
new file mode 100644
index 0000000..e5bfb17
--- /dev/null
+++ b/src/main/java/com/example/repository/PetRepository.java
@@ -0,0 +1,60 @@
+package com.example.repository;
+
+import com.example.api.ApiError;
+import com.example.api.Pet;
+import com.example.api.RepositoryType;
+import com.leakyabstractions.result.api.Result;
+
+import java.util.Collection;
+
+/**
+ * Represents a pet repository.
+ */
+public interface PetRepository {
+
+ /**
+ * Returns the type of this repository.
+ *
+ * @return the type of this repository
+ */
+ RepositoryType getType();
+
+ /**
+ * Returns all pets in this repository.
+ *
+ * @return all pets in this repository
+ */
+ Result, ApiError> listPets();
+
+ /**
+ * Adds a new pet to this repository
+ *
+ * @param pet The pet to add
+ * @return The added pet
+ */
+ Result createPet(Pet pet);
+
+ /**
+ * Updates an existing pet in this repository.
+ *
+ * @param pet The pet to update
+ * @return The updated pet
+ */
+ Result updatePet(Pet pet);
+
+ /**
+ * Returns a single pet by ID.
+ *
+ * @param id The pet ID to find
+ * @return A single pet
+ */
+ Result findPet(Long id);
+
+ /**
+ * Deletes a pet by ID.
+ *
+ * @param id The pet ID to delete
+ * @return `true` if the pet was deleted
+ */
+ Result deletePet(Long id);
+}
diff --git a/src/main/java/com/example/repository/RemoteRepository.java b/src/main/java/com/example/repository/RemoteRepository.java
new file mode 100644
index 0000000..6f5d458
--- /dev/null
+++ b/src/main/java/com/example/repository/RemoteRepository.java
@@ -0,0 +1,45 @@
+package com.example.repository;
+
+import com.example.api.ApiError;
+import com.example.api.ApiResponse;
+import com.example.api.RepositoryType;
+import com.leakyabstractions.result.api.Result;
+import com.leakyabstractions.result.core.Results;
+import com.leakyabstractions.result.lazy.LazyResults;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+import java.util.function.Supplier;
+
+/**
+ * Base class for all remote pet repositories.
+ *
+ * Interacts with a remote pet store server via HTTP.
+ */
+@Service
+abstract class RemoteRepository implements PetRepository {
+
+ final RepositoryType type;
+
+ RemoteRepository(RepositoryType type) {
+ this.type = type;
+ }
+
+ @Override
+ public RepositoryType getType() {
+ return type;
+ }
+
+ protected Result evaluate(Supplier> call) {
+ return LazyResults.ofSupplier(
+ () -> Results.ofCallable(() -> Optional.ofNullable(call.get()).orElseGet(ApiResponse::new)))
+ .mapFailure(ApiError::remote)
+ .flatMapSuccess(ApiResponse::getResult);
+ }
+
+ protected Result evaluateAny(Supplier call) {
+ return LazyResults.ofSupplier(
+ () -> Results.ofCallable(() -> Optional.ofNullable(call.get()).orElseThrow()))
+ .mapFailure(ApiError::remote);
+ }
+}
diff --git a/src/main/java/com/example/repository/SwaggerRepository.java b/src/main/java/com/example/repository/SwaggerRepository.java
new file mode 100644
index 0000000..51e7a1d
--- /dev/null
+++ b/src/main/java/com/example/repository/SwaggerRepository.java
@@ -0,0 +1,65 @@
+package com.example.repository;
+
+import com.example.api.ApiError;
+import com.example.api.Pet;
+import com.example.client.SwaggerClient;
+import com.leakyabstractions.result.api.Result;
+import org.slf4j.Logger;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+
+import static com.example.api.RepositoryType.SWAGGER;
+import static org.slf4j.LoggerFactory.getLogger;
+
+/**
+ * Implements the Swagger pet repository.
+ *
+ * Interacts with Swagger's sample pet store server.
+ */
+@Service
+class SwaggerRepository extends RemoteRepository implements PetRepository {
+
+ static final Logger log = getLogger(SwaggerRepository.class);
+
+ final SwaggerClient swagger;
+
+ SwaggerRepository(SwaggerClient swagger) {
+ super(SWAGGER);
+ this.swagger = swagger;
+ }
+
+ @Override
+ public Result, ApiError> listPets() {
+ log.info("List pets");
+ return evaluateAny(swagger::remoteList);
+ }
+
+ @Override
+ public Result createPet(Pet pet) {
+ log.info("Create pet: {}", pet);
+ return evaluateAny(() -> swagger.remoteCreate(pet));
+ }
+
+ @Override
+ public Result updatePet(Pet pet) {
+ log.info("Update pet: {}", pet);
+ return evaluateAny(() -> swagger.remoteUpdate(pet));
+ }
+
+ @Override
+ public Result findPet(Long id) {
+ log.info("Find pet by ID: {}", id);
+ return evaluateAny(() -> swagger.remoteFindById(id));
+ }
+
+ @Override
+ public Result deletePet(Long id) {
+ log.info("Delete pet by ID: {}", id);
+ return evaluateAny(() -> swagger.remoteDeleteById(id))
+ .mapSuccess(ResponseEntity::getStatusCode)
+ .mapSuccess(HttpStatus.OK::equals);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..5af1a1c
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,25 @@
+spring.application.name=Example
+
+spring.jackson.default-property-inclusion=non_null
+
+spring.cloud.openfeign.autoconfiguration.jackson.enabled=true
+spring.cloud.openfeign.client.config.loopback.url=http://localhost:${server.port:8080}
+spring.cloud.openfeign.client.config.loopback.default-request-headers.x-type=LOCAL
+spring.cloud.openfeign.client.config.github.url=https://dev.leakyabstractions.com
+spring.cloud.openfeign.client.config.swagger.url=https://petstore.swagger.io
+spring.cloud.openfeign.client.config.swagger.default-request-headers.api_key=special-key
+
+pet-store.api-version=1.0
+pet-store.enabled=true
+
+pet-store.pets[0].id=0
+pet-store.pets[0].name=Rocky
+pet-store.pets[0].status=available
+
+pet-store.pets[1].id=1
+pet-store.pets[1].name=Garfield
+pet-store.pets[1].status=sold
+
+pet-store.pets[2].id=2
+pet-store.pets[2].name=Rantanplan
+pet-store.pets[2].status=pending
diff --git a/src/test/java/com/example/ExampleApplicationTests.java b/src/test/java/com/example/ExampleApplicationTests.java
new file mode 100644
index 0000000..0d460f6
--- /dev/null
+++ b/src/test/java/com/example/ExampleApplicationTests.java
@@ -0,0 +1,108 @@
+package com.example;
+
+import com.example.api.ApiResponse;
+import com.example.api.Pet;
+import com.example.client.LoopbackClient;
+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.context.TestConfiguration;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpEntity;
+
+import java.util.Collection;
+
+import static com.example.api.ApiErrorCode.PET_STORE_UNAVAILABLE;
+import static com.example.api.RepositoryType.*;
+import static com.leakyabstractions.result.assertj.InstanceOfResultAssertFactories.LIST;
+import static com.leakyabstractions.result.assertj.InstanceOfResultAssertFactories.RESULT;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
+import static org.springframework.http.RequestEntity.get;
+
+@SpringBootTest(webEnvironment = RANDOM_PORT)
+class ExampleApplicationTests {
+
+ static final ParameterizedTypeReference> PET = new ParameterizedTypeReference<>() { };
+ static final ParameterizedTypeReference>> PETS = new ParameterizedTypeReference<>() { };
+
+ @Autowired
+ TestRestTemplate rest;
+
+ @Test
+ void contextLoads() {
+ }
+
+ @Test
+ void testIndexController() {
+ assertThat(rest.getForEntity("/", String.class).getBody())
+ .contains("swagger-ui.css");
+ }
+
+ @Test
+ void testPetControllerLocal() {
+ assertThat(rest.exchange(get("/pet").header("X-Type", LOCAL.name()).build(), PETS))
+ .extracting(HttpEntity::getBody)
+ .extracting(ApiResponse::getResult)
+ .asInstanceOf(RESULT)
+ .hasSuccessThat(LIST)
+ .hasSize(3);
+ }
+
+ @Test
+ void testPetControllerLoopback() {
+ assertThat(rest.exchange(get("/pet/0").header("X-Type", LOOPBACK.name()).build(), PET))
+ .extracting(HttpEntity::getBody)
+ .extracting(ApiResponse::getResult)
+ .asInstanceOf(RESULT)
+ .hasSuccessSatisfying(pet -> assertThat(pet).hasFieldOrPropertyWithValue("name", "Rocky"));
+ }
+
+ @Test
+ void testPetControllerSpecial() {
+ assertThat(rest.exchange(get("/pet").header("X-Type", SPECIAL.name()).build(), PETS))
+ .extracting(HttpEntity::getBody)
+ .extracting(ApiResponse::getResult)
+ .asInstanceOf(RESULT)
+ .hasFailureSatisfying(error -> assertThat(error).hasFieldOrPropertyWithValue("code", PET_STORE_UNAVAILABLE));
+ }
+
+ @TestConfiguration
+ static class TestConfig {
+ @Bean
+ @Primary
+ LoopbackClient loopbackClient(TestRestTemplate rest) {
+ return new LoopbackClient() {
+ @Override
+ public ApiResponse remoteFindById(Long id) {
+ return rest
+ .exchange(get("/pet/" + id).header("X-Type", LOCAL.name()).build(), PET)
+ .getBody();
+ }
+
+ @Override
+ public ApiResponse> remoteList() {
+ throw new RuntimeException("Not implemented");
+ }
+
+ @Override
+ public ApiResponse remoteDeleteById(Long id) {
+ throw new RuntimeException("Not implemented");
+ }
+
+ @Override
+ public ApiResponse remoteCreate(Pet pet) {
+ throw new RuntimeException("Not implemented");
+ }
+
+ @Override
+ public ApiResponse remoteUpdate(Pet pet) {
+ throw new RuntimeException("Not implemented");
+ }
+ };
+ }
+ }
+}
diff --git a/src/test/resources/static/1.json b/src/test/resources/static/1.json
new file mode 100644
index 0000000..dd14d35
--- /dev/null
+++ b/src/test/resources/static/1.json
@@ -0,0 +1,10 @@
+{
+ "version": 1,
+ "result": {
+ "success": {
+ "id": 1,
+ "name": "Foo",
+ "status": "available"
+ }
+ }
+}
diff --git a/src/test/resources/static/2.json b/src/test/resources/static/2.json
new file mode 100644
index 0000000..2b87053
--- /dev/null
+++ b/src/test/resources/static/2.json
@@ -0,0 +1,10 @@
+{
+ "version": 1,
+ "result": {
+ "success": {
+ "id": 2,
+ "name": "Bar",
+ "status": "sold"
+ }
+ }
+}
diff --git a/src/test/resources/static/3.json b/src/test/resources/static/3.json
new file mode 100644
index 0000000..6f1ae34
--- /dev/null
+++ b/src/test/resources/static/3.json
@@ -0,0 +1,10 @@
+{
+ "version": 1,
+ "result": {
+ "success": {
+ "id": 3,
+ "name": "Foobar",
+ "status": "pending"
+ }
+ }
+}
diff --git a/src/test/resources/static/all.json b/src/test/resources/static/all.json
new file mode 100644
index 0000000..55e0b02
--- /dev/null
+++ b/src/test/resources/static/all.json
@@ -0,0 +1,22 @@
+{
+ "version": 1,
+ "result": {
+ "success": [
+ {
+ "id": 1,
+ "name": "Foo",
+ "status": "available"
+ },
+ {
+ "id": 2,
+ "name": "Bar",
+ "status": "sold"
+ },
+ {
+ "id": 3,
+ "name": "Foobar",
+ "status": "pending"
+ }
+ ]
+ }
+}
diff --git a/swagger-ui.png b/swagger-ui.png
new file mode 100644
index 0000000..44fff45
Binary files /dev/null and b/swagger-ui.png differ