Skip to content

Commit

Permalink
Expose Query Jars API
Browse files Browse the repository at this point in the history
  • Loading branch information
semotpan committed Aug 14, 2024
1 parent b9846d4 commit f931e9a
Show file tree
Hide file tree
Showing 9 changed files with 335 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import io.myfinbox.rest.JarCategoryModificationResource;
import io.myfinbox.rest.JarResource;
import io.myfinbox.shared.ApiFailureHandler;
import io.myfinbox.shared.Failure;
import io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase;
import io.myfinbox.spendingplan.application.AddOrRemoveJarCategoryUseCase.JarCategoryToAddOrRemove;
import io.myfinbox.spendingplan.application.CreateJarUseCase;
import io.myfinbox.spendingplan.application.JarCommand;
import io.myfinbox.spendingplan.application.JarQuery;
import lombok.RequiredArgsConstructor;
import org.springframework.core.convert.ConversionService;
import org.springframework.http.ResponseEntity;
Expand All @@ -28,6 +30,7 @@ final class JarController implements JarsApi {

private final CreateJarUseCase createJarUseCase;
private final AddOrRemoveJarCategoryUseCase addOrRemoveJarCategoryUseCase;
private final JarQuery jarQuery;
private final ApiFailureHandler apiFailureHandler;
private final ConversionService conversionService;

Expand All @@ -46,6 +49,31 @@ public ResponseEntity<?> modifyExpenseCategories(@PathVariable UUID planId,
.fold(apiFailureHandler::handle, ok -> ok().build());
}

@GetMapping(path = "/{planId}/jars/{jarId}", produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> one(@PathVariable UUID planId, @PathVariable UUID jarId) {
var jars = jarQuery.search()
.withPlanId(planId)
.withJarId(jarId)
.list();

if (jars.isEmpty()) {
return apiFailureHandler.handle(Failure.ofNotFound("Jar with ID '%s' for plan ID '%s' was not found.".formatted(jarId, planId)));
}

return ok().body(conversionService.convert(jars.getFirst(), JarResource.class));
}

@GetMapping(path = "/{planId}/jars", produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> list(@PathVariable("planId") UUID planId) {
var jars = jarQuery.search()
.withPlanId(planId)
.list();

return ok().body(jars.stream()
.map(jar -> conversionService.convert(jar, JarResource.class))
.toList());
}

private JarCommand toCommand(JarResource resource) {
return JarCommand.builder()
.name(resource.getName())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
Expand Down Expand Up @@ -71,4 +72,35 @@ ResponseEntity<?> modifyExpenseCategories(
@Parameter(in = PATH, description = "ID of the spending jar to modify categories", required = true) UUID jarId,
@RequestBody(description = "Resource containing categories to add or remove", required = true) JarCategoryModificationResource resource);

@Operation(summary = "Query a list of jars for a specified planId in the MyFinBox",
description = "Query a list of jars for a specified planId in the MyFinBox",
security = {@SecurityRequirement(name = "openId")},
tags = {TAG})
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successful Operation",
content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = JarResource.class)))),
@ApiResponse(responseCode = "400", description = "Malformed JSON or Type Mismatch Failure",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "500", description = "Internal Server Error",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)))
})
ResponseEntity<?> list(@Parameter(in = PATH, description = "Plan Id to be used for selecting spending jars", required = true) UUID planId);

@Operation(summary = "Query a spending jar for a specified jar ID and plan ID in the MyFinBox",
description = "Query a spending jar for a specified jar ID and plan ID in the MyFinBox",
security = {@SecurityRequirement(name = "openId")},
tags = {TAG})
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successful Operation",
content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = JarResource.class)))),
@ApiResponse(responseCode = "400", description = "Malformed JSON or Type Mismatch Failure",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "Jar not found Failure",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class))),
@ApiResponse(responseCode = "500", description = "Internal Server Error",
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)))
})
ResponseEntity<?> one(@Parameter(in = PATH, description = "Plan Id to be used for searching", required = true) UUID planId,
@Parameter(in = PATH, description = "Jar Id to be used for searching", required = true) UUID jarId);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package io.myfinbox.spendingplan.application;

import io.myfinbox.spendingplan.domain.Jar;

import java.util.List;
import java.util.UUID;

/**
* Represents a query interface for searching and retrieving jars.
*/
public interface JarQuery {

/**
* Initiates a search for jars.
*
* @return a builder to further customize the jar search.
*/
JarQueryBuilder search();

/**
* Builder interface for constructing and executing a jar query.
*/
interface JarQueryBuilder {

/**
* Filters the jars by the specified plan ID.
*
* @param planId the unique identifier of the plan.
* @return the updated query builder.
*/
JarQueryBuilder withPlanId(UUID planId);

/**
* Filters the jars by the specified jar ID.
*
* @param jarId the unique identifier of the jar.
* @return the updated query builder.
*/
JarQueryBuilder withJarId(UUID jarId);

/**
* Executes the query and returns a list of jars matching the criteria.
*
* @return a list of jars matching the query criteria.
*/
List<Jar> list();
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package io.myfinbox.spendingplan.application;

import io.myfinbox.spendingplan.domain.Jar;
import io.myfinbox.spendingplan.domain.JarIdentifier;
import io.myfinbox.spendingplan.domain.Jars;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;

import static io.myfinbox.spendingplan.domain.Plan.*;
import static java.util.Collections.emptyList;
import static java.util.Objects.nonNull;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
class JarQueryService implements JarQuery {

private final Jars jars;

@Override
public JarQueryBuilder search() {
return new DefaultJarQueryBuilder(jars);
}

@RequiredArgsConstructor
private static final class DefaultJarQueryBuilder implements JarQueryBuilder {

private final Jars jars;

private UUID planId;
private UUID jarId;

@Override
public JarQueryBuilder withPlanId(UUID planId) {
this.planId = planId;
return this;
}

@Override
public JarQueryBuilder withJarId(UUID jarId) {
this.jarId = jarId;
return this;
}

@Override
public List<Jar> list() {
if (nonNull(planId) && nonNull(jarId)) {
return jars.findByIdAndPlanId(new JarIdentifier(jarId), new PlanIdentifier(planId))
.map(List::of)
.orElse(emptyList());
}

if (nonNull(jarId)) {
return jars.findById(new JarIdentifier(jarId))
.map(List::of)
.orElse(emptyList());
}

if (nonNull(planId)) {
return jars.findByPlanId(new PlanIdentifier(planId));
}

return emptyList();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
Expand All @@ -18,4 +19,10 @@ public interface Jars extends JpaRepository<Jar, JarIdentifier> {
""")
Optional<Jar> findByIdAndPlanId(JarIdentifier jarId, PlanIdentifier planId);

@Query(value = """
SELECT j FROM Jar j
WHERE j.plan.id = :planId
""")
List<Jar> findByPlanId(PlanIdentifier planId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,12 @@ class DataSamples {
static newSampleExpenseDeletedEvent(map = [:]) {
MAPPER.readValue(JsonOutput.toJson(EXPENSE_EVENT + map) as String, ExpenseDeleted.class)
}

static newSampleJarAsString() {
JsonOutput.toJson(JAR_PLAIN)
}

static newSampleListJarAsString() {
JsonOutput.toJson([JAR_PLAIN])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.core.io.ClassPathResource
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.ResponseEntity
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.modulith.test.ApplicationModuleTest
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.jdbc.JdbcTestUtils
import org.springframework.web.util.UriComponentsBuilder
import spock.lang.Specification
import spock.lang.Tag

Expand Down Expand Up @@ -136,6 +138,41 @@ class JarControllerSpec extends Specification {
JSONAssert.assertEquals(expectedCategoriesModificationFailure(), response.getBody(), LENIENT)
}

@Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql'])
def "should get one existing spending jar"() {
when: 'getting one jar'
def response = getOneJar(UUID.fromString(planId), UUID.fromString(jarId))

then: 'the response status is "OK"'
assert response.getStatusCode() == OK

and: 'the response body contains the expected resource'
JSONAssert.assertEquals(newSampleJarAsString(), response.getBody(), LENIENT)
}

def "should get not found when spending jar was not found"() {
when: 'getting one jar'
def response = getOneJar(UUID.fromString(planId), UUID.fromString(jarId))

then: 'the response status is "NOT_FOUND"'
assert response.getStatusCode() == NOT_FOUND

and: 'the response body contains the not found failure'
JSONAssert.assertEquals(expectedJarNotFoundFailure(), response.getBody(), LENIENT)
}

@Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql'])
def "should get a list with one existing spending jar for provided planId"() {
when: 'listing jars by planId'
def response = listJars(UUID.fromString(planId))

then: 'the response status is "OK"'
assert response.getStatusCode() == OK

and: 'the response body contains the expected resource'
JSONAssert.assertEquals(newSampleListJarAsString(), response.getBody(), LENIENT)
}

def postJar(String req) {
restTemplate.postForEntity("/v1/plans/${planId}/jars", entityRequest(req), String.class)
}
Expand All @@ -149,6 +186,32 @@ class JarControllerSpec extends Specification {
)
}

def getOneJar(UUID planId, UUID jarId) {
def uri = UriComponentsBuilder.fromUriString("${restTemplate.getRootUri()}/v1/plans/${planId}/jars/${jarId}")
.build()
.toUri()

restTemplate.exchange(
uri,
HttpMethod.GET,
null,
String.class
)
}

def listJars(UUID planId) {
def uri = UriComponentsBuilder.fromUriString("${restTemplate.getRootUri()}/v1/plans/${planId}/jars")
.build()
.toUri()

restTemplate.exchange(
uri,
HttpMethod.GET,
null,
String.class
)
}

def entityRequest(String req) {
var headers = new HttpHeaders()
headers.setContentType(APPLICATION_JSON)
Expand Down Expand Up @@ -188,4 +251,12 @@ class JarControllerSpec extends Specification {
message : "Spending plan jar was not found."
])
}

def expectedJarNotFoundFailure() {
JsonOutput.toJson([
status : 404,
errorCode: "NOT_FOUND",
message : "Jar with ID '${jarId}' for plan ID '${planId}' was not found."
])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ class PlanControllerSpec extends Specification {
}

@Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql'])
def "should list a list with one spending plan for provided account"() {
def "should get a list with one spending plan for provided account"() {
when: 'listing plans by accountId'
def response = listPlans(UUID.fromString(accountId))

Expand Down
Loading

0 comments on commit f931e9a

Please sign in to comment.