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 Plans API #41

Merged
merged 1 commit into from
Aug 12, 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
@@ -1,14 +1,14 @@
package io.myfinbox.spendingplan.adapter.web;

import io.myfinbox.rest.CreateJarResource;
import io.myfinbox.rest.JarCategoryModificationResource;
import io.myfinbox.rest.JarResource;
import io.myfinbox.shared.ApiFailureHandler;
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.domain.Jar;
import lombok.RequiredArgsConstructor;
import org.springframework.core.convert.ConversionService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -29,12 +29,13 @@ final class JarController implements JarsApi {
private final CreateJarUseCase createJarUseCase;
private final AddOrRemoveJarCategoryUseCase addOrRemoveJarCategoryUseCase;
private final ApiFailureHandler apiFailureHandler;
private final ConversionService conversionService;

@PostMapping(path = "/{planId}/jars", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> create(@PathVariable UUID planId, @RequestBody CreateJarResource resource) {
public ResponseEntity<?> create(@PathVariable UUID planId, @RequestBody JarResource resource) {
return createJarUseCase.create(planId, toCommand(resource))
.fold(apiFailureHandler::handle, jar -> created(fromCurrentRequest().path("/{id}").build(jar.getId().id()))
.body(toResource(jar)));
.body(conversionService.convert(jar, JarResource.class)));
}

@PutMapping(path = "/{planId}/jars/{jarId}/expense-categories", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
Expand All @@ -45,25 +46,14 @@ public ResponseEntity<?> modifyExpenseCategories(@PathVariable UUID planId,
.fold(apiFailureHandler::handle, ok -> ok().build());
}

private JarCommand toCommand(CreateJarResource resource) {
private JarCommand toCommand(JarResource resource) {
return JarCommand.builder()
.name(resource.getName())
.percentage(resource.getPercentage())
.description(resource.getDescription())
.build();
}

private CreateJarResource toResource(Jar jar) {
return new CreateJarResource()
.jarId(jar.getId().id())
.creationTimestamp(jar.getCreationTimestamp().toString())
.amountToReach(jar.getAmountToReachAsNumber())
.currencyCode(jar.getCurrencyCode())
.name(jar.getName())
.percentage(jar.getPercentage().value())
.description(jar.getDescription());
}

private JarCategoriesCommand toCommand(JarCategoryModificationResource resource) {
var categoryToAdds = resource.getCategories()
.stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package io.myfinbox.spendingplan.adapter.web;

import io.myfinbox.rest.CreateJarResource;
import io.myfinbox.rest.JarCategoryModificationResource;
import io.myfinbox.rest.JarResource;
import io.myfinbox.shared.ApiErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
Expand Down Expand Up @@ -32,7 +32,7 @@ public interface JarsApi {
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Successful Operation",
headers = @Header(name = LOCATION, description = "Created jar source URI location", schema = @Schema(implementation = URI.class)),
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = CreateJarResource.class))),
content = @Content(mediaType = APPLICATION_JSON_VALUE, 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 = "Spending plan not found",
Expand All @@ -45,7 +45,7 @@ public interface JarsApi {
content = @Content(mediaType = APPLICATION_JSON_VALUE, schema = @Schema(implementation = ApiErrorResponse.class)))
})
ResponseEntity<?> create(@Parameter(in = PATH, description = "Spending plan ID to be added new jar", required = true) UUID planId,
@RequestBody(description = "Jar Resource to be created", required = true) CreateJarResource resource);
@RequestBody(description = "Jar Resource to be created", required = true) JarResource resource);

@Operation(
summary = "Modify Expense Categories for Spending Plan Jar",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@
import io.myfinbox.rest.CreateClassicPlanResource;
import io.myfinbox.rest.PlanResource;
import io.myfinbox.shared.ApiFailureHandler;
import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase;
import io.myfinbox.shared.Failure;
import io.myfinbox.spendingplan.application.*;
import io.myfinbox.spendingplan.application.ClassicPlanBuilderUseCase.CreateClassicPlanCommand;
import io.myfinbox.spendingplan.application.CreatePlanUseCase;
import io.myfinbox.spendingplan.application.PlanCommand;
import io.myfinbox.spendingplan.application.UpdatePlanUseCase;
import io.myfinbox.spendingplan.domain.Plan;
import lombok.RequiredArgsConstructor;
import org.springframework.core.convert.ConversionService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -28,37 +26,52 @@ final class PlanController implements PlansApi {
private final CreatePlanUseCase createPlanUseCase;
private final ClassicPlanBuilderUseCase classicPlanBuilderUseCase;
private final UpdatePlanUseCase updatePlanUseCase;
private final PlanQuery planQuery;
private final ApiFailureHandler apiFailureHandler;
private final ConversionService conversionService;

@PostMapping(consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> create(@RequestBody PlanResource resource) {
return createPlanUseCase.create(toCommand(resource))
.fold(apiFailureHandler::handle, plan -> created(fromCurrentRequest().path("/{id}").build(plan.getId().id()))
.body(toResource(plan)));
.body(conversionService.convert(plan, PlanResource.class)));
}

@PostMapping(path = "/classic", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> createClassic(@RequestBody CreateClassicPlanResource resource) {
return classicPlanBuilderUseCase.create(toCommand(resource))
.fold(apiFailureHandler::handle, plan -> created(fromCurrentRequest().path("/{id}").build(plan.getId().id()))
.body(toResource(plan)));
.body(conversionService.convert(plan, PlanResource.class)));
}

@PutMapping(path = "/{planId}", consumes = APPLICATION_JSON_VALUE, produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> update(@PathVariable UUID planId, @RequestBody PlanResource resource) {
return updatePlanUseCase.update(planId, toCommand(resource))
.fold(apiFailureHandler::handle, plan -> ok().body(toResource(plan)));
.fold(apiFailureHandler::handle, plan -> ok().body(conversionService.convert(plan, PlanResource.class)));
}

private PlanResource toResource(Plan plan) {
return new PlanResource()
.planId(plan.getId().id())
.name(plan.getName())
.creationTimestamp(plan.getCreationTimestamp().toString())
.amount(plan.getAmountAsNumber())
.currencyCode(plan.getCurrencyCode())
.accountId(plan.getAccount().id())
.description(plan.getDescription());
@GetMapping(path = "/{planId}", produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> one(@PathVariable UUID planId) {
var plans = planQuery.search()
.withPlanId(planId)
.list();

if (plans.isEmpty()) {
return apiFailureHandler.handle(Failure.ofNotFound("Plan with ID '%s' was not found.".formatted(planId)));
}

return ok().body(conversionService.convert(plans.getFirst(), PlanResource.class));
}

@GetMapping(produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> list(@RequestParam("accountId") UUID accountId) {
var plans = planQuery.search()
.withAccountId(accountId)
.list();

return ok().body(plans.stream()
.map(plan -> conversionService.convert(plan, PlanResource.class))
.toList());
}

private PlanCommand toCommand(PlanResource resource) {
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 All @@ -18,6 +19,7 @@
import java.util.UUID;

import static io.swagger.v3.oas.annotations.enums.ParameterIn.PATH;
import static io.swagger.v3.oas.annotations.enums.ParameterIn.QUERY;
import static org.springframework.http.HttpHeaders.LOCATION;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;

Expand Down Expand Up @@ -84,4 +86,35 @@ public interface PlansApi {
})
ResponseEntity<?> update(@Parameter(in = PATH, description = "PlanId to be updated", required = true) UUID planId,
@RequestBody(description = "Spending Plan Resource to be updated", required = true) PlanResource resource);

@Operation(summary = "Query a list of spending plans for a specified account in the MyFinBox",
description = "Query a list of spending plans for a specified account 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 = PlanResource.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 = QUERY, description = "Account to be used for selecting spending plans", required = true) UUID accountId);

@Operation(summary = "Query a spending plan for a specified plan ID in the MyFinBox",
description = "Query a spending plan for a specified 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 = PlanResource.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 = "Plan 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);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.myfinbox.spendingplan.adapter.web.converters;

import io.myfinbox.rest.JarResource;
import io.myfinbox.spendingplan.domain.Jar;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

@Component
final class JarToResourceConverter implements Converter<Jar, JarResource> {

@Override
public JarResource convert(Jar jar) {
return new JarResource()
.jarId(jar.getId().id())
.creationTimestamp(jar.getCreationTimestamp().toString())
.amountToReach(jar.getAmountToReachAsNumber())
.currencyCode(jar.getCurrencyCode())
.name(jar.getName())
.percentage(jar.getPercentage().value())
.description(jar.getDescription());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.myfinbox.spendingplan.adapter.web.converters;

import io.myfinbox.rest.JarResource;
import io.myfinbox.rest.PlanResource;
import io.myfinbox.spendingplan.domain.Jar;
import io.myfinbox.spendingplan.domain.Plan;
import lombok.RequiredArgsConstructor;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
final class PlanToResourceConverter implements Converter<Plan, PlanResource> {

private final Converter<Jar, JarResource> jarResourceConverter;

@Override
public PlanResource convert(Plan plan) {
return new PlanResource()
.planId(plan.getId().id())
.name(plan.getName())
.creationTimestamp(plan.getCreationTimestamp().toString())
.amount(plan.getAmountAsNumber())
.currencyCode(plan.getCurrencyCode())
.accountId(plan.getAccount().id())
.description(plan.getDescription())
.jars(plan.getJars().stream()
.map(jarResourceConverter::convert)
.toList());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.myfinbox.spendingplan.application;

import io.myfinbox.spendingplan.domain.Plan;

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

/**
* Represents a query interface for searching and retrieving plans.
*/
public interface PlanQuery {

/**
* Initiates a search for plans.
*
* @return a builder to further customize the plan search.
*/
PlanQueryBuilder search();

/**
* Builder interface for constructing and executing a plan query.
*/
interface PlanQueryBuilder {

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

/**
* Filters the plans by the specified account ID.
*
* @param accountId the unique identifier of the account.
* @return the updated query builder.
*/
PlanQueryBuilder withAccountId(UUID accountId);

/**
* Executes the query and returns a list of plans matching the criteria.
*
* @return a list of plans matching the query criteria.
*/
List<Plan> 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.AccountIdentifier;
import io.myfinbox.spendingplan.domain.Plan;
import io.myfinbox.spendingplan.domain.Plans;
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.PlanIdentifier;
import static java.util.Collections.emptyList;
import static java.util.Objects.nonNull;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
class PlanQueryService implements PlanQuery {

private final Plans plans;

@Override
public PlanQueryBuilder search() {
return new DefaultPlanQueryBuilder(plans);
}

@RequiredArgsConstructor
private static class DefaultPlanQueryBuilder implements PlanQueryBuilder {

private final Plans plans;

private UUID planId;
private UUID accountId;

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

@Override
public PlanQueryBuilder withAccountId(UUID accountId) {
this.accountId = accountId;
return this;
}

@Override
public List<Plan> list() {
if (nonNull(planId) && nonNull(accountId)) {
return plans.findByIdAndAccountIdEagerJars(new PlanIdentifier(planId), new AccountIdentifier(accountId))
.map(List::of)
.orElse(emptyList());
}

if (nonNull(accountId)) {
return plans.findByAccountIdEagerJars(new AccountIdentifier(accountId));
}

if (nonNull(planId)) {
return plans.findByIdEagerJars(new PlanIdentifier(planId))
.map(List::of)
.orElse(emptyList());
}

return emptyList();
}
}
}
Loading
Loading