Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose Query Jars API #42

Merged
merged 1 commit into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading