Skip to content

Commit

Permalink
Enhance jar expense category query api
Browse files Browse the repository at this point in the history
  • Loading branch information
semotpan committed Aug 17, 2024
1 parent 7414699 commit b2410c3
Show file tree
Hide file tree
Showing 14 changed files with 250 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
package io.myfinbox.spendingplan.adapter.web;

import io.myfinbox.rest.JarCategoryModificationResource;
import io.myfinbox.rest.JarExpenseCategoryResource;
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.*;
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 @@ -31,6 +29,7 @@ final class JarController implements JarsApi {
private final CreateJarUseCase createJarUseCase;
private final AddOrRemoveJarCategoryUseCase addOrRemoveJarCategoryUseCase;
private final JarQuery jarQuery;
private final JarExpenseCategoryQuery jarExpenseCategoryQuery;
private final ApiFailureHandler apiFailureHandler;
private final ConversionService conversionService;

Expand Down Expand Up @@ -74,6 +73,13 @@ public ResponseEntity<?> list(@PathVariable("planId") UUID planId) {
.toList());
}

@GetMapping(path = "/{planId}/jars/{jarId}/expense-categories", produces = APPLICATION_JSON_VALUE)
public ResponseEntity<?> list(@PathVariable UUID planId, @PathVariable UUID jarId) {
return ok().body(jarExpenseCategoryQuery.search(planId, jarId).stream()
.map(expenseCategory -> conversionService.convert(expenseCategory, JarExpenseCategoryResource.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 @@ -103,4 +103,19 @@ ResponseEntity<?> modifyExpenseCategories(
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);

@Operation(summary = "Query a list of attached expense categories to a jar in the MyFinBox",
description = "Query a list of attached expense categories to a jar 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 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,19 @@
package io.myfinbox.spendingplan.adapter.web.converters;

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

@Component
final class JarExpenseCategoryToResourceConverter implements Converter<JarExpenseCategory, JarExpenseCategoryResource> {

@Override
public JarExpenseCategoryResource convert(JarExpenseCategory category) {
return new JarExpenseCategoryResource()
.id(category.getId())
.categoryId(category.getCategoryId().id())
.categoryName(category.getCategoryName())
.creationTimestamp(category.getCreationTimestamp().toString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import io.vavr.control.Validation;

import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

import static io.myfinbox.shared.Failure.FieldViolation;
Expand All @@ -12,7 +13,6 @@
import static io.vavr.API.Invalid;
import static io.vavr.API.Valid;
import static java.util.Objects.isNull;
import static org.apache.commons.lang3.StringUtils.isBlank;

final class CategoriesJarCommandValidator {

Expand All @@ -26,12 +26,13 @@ Validation<FieldViolation, List<JarCategoryToAddOrRemove>> validate(List<JarCate
}

var anyNull = categories.stream()
.anyMatch(category -> isNull(category.categoryId()) || isBlank(category.categoryName()));
.map(JarCategoryToAddOrRemove::categoryId)
.anyMatch(Objects::isNull);

if (anyNull) {
return Invalid(Failure.FieldViolation.builder()
.field(CATEGORIES_JAR_FIELD)
.message("Null categoryId or blank categoryName not allowed.")
.message("Null categoryId not allowed.")
.rejectedValue(categories)
.build());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.myfinbox.spendingplan.application;

import io.myfinbox.spendingplan.domain.JarExpenseCategory;

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

/**
* Represents a query interface for searching and retrieving jar expense categories.
*/
public interface JarExpenseCategoryQuery {

/**
* Searches for jar expense categories based on the specified plan ID and jar ID.
*
* @param planId the unique identifier of the plan.
* @param jarId the unique identifier of the jar.
* @return a list of jar expense categories matching the specified plan ID and jar ID.
*/
List<JarExpenseCategory> search(UUID planId, UUID jarId);

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

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

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

import static java.util.Collections.emptyList;
import static java.util.Objects.isNull;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
class JarExpenseCategoryQueryService implements JarExpenseCategoryQuery {

private final JarExpenseCategories jarExpenseCategories;
private final Jars jars;

@Override
public List<JarExpenseCategory> search(UUID planId, UUID jarId) {
if (isNull(planId) || isNull(jarId)) {
return emptyList();
}

if (!jars.existsByIdAndPlan(new Plan.PlanIdentifier(planId), new JarIdentifier(jarId))) {
return emptyList();
}

return jarExpenseCategories.findByJarId(new JarIdentifier(jarId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
import java.util.ArrayList;
import java.util.List;

import static io.myfinbox.shared.Guards.*;
import static io.myfinbox.shared.Guards.notNull;
import static jakarta.persistence.CascadeType.ALL;
import static jakarta.persistence.GenerationType.SEQUENCE;
import static lombok.AccessLevel.PACKAGE;
import static org.apache.commons.lang3.StringUtils.isBlank;

@Entity
@Getter
Expand All @@ -32,7 +33,7 @@ public class JarExpenseCategory {
@AttributeOverride(name = "id", column = @Column(name = "category_id"))
private final CategoryIdentifier categoryId;

@Column(name = "category_name", nullable = false)
@Column(name = "category_name")
private String categoryName;

@ManyToOne(fetch = FetchType.LAZY)
Expand All @@ -46,7 +47,7 @@ public class JarExpenseCategory {
public JarExpenseCategory(Jar jar, CategoryIdentifier categoryId, String categoryName) {
this.jar = notNull(jar, "jar cannot be null.");
this.categoryId = notNull(categoryId, "categoryId cannot be null.");
this.categoryName = notBlank(categoryName, "categoryName cannot be null.");
this.categoryName = !isBlank(categoryName) ? categoryName.trim() : null;
this.creationTimestamp = Instant.now();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,9 @@ public interface Jars extends JpaRepository<Jar, JarIdentifier> {
""")
List<Jar> findByPlanId(PlanIdentifier planId);

@Query(value = """
SELECT COUNT(j) > 0 FROM Jar j
WHERE j.id = :jarId AND j.plan.id = :planId
""")
boolean existsByIdAndPlan(PlanIdentifier planId, JarIdentifier jarId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ CREATE SEQUENCE IF NOT EXISTS sjec_seq_id START 1 INCREMENT 1;

CREATE TABLE IF NOT EXISTS spending_jar_expense_category
(
id BIGINT PRIMARY KEY DEFAULT nextval('sjec_seq_id'),
jar_id UUID NOT NULL,
category_id UUID NOT NULL,
category_name VARCHAR(100) NOT NULL,
creation_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
id BIGINT PRIMARY KEY DEFAULT nextval('sjec_seq_id'),
jar_id UUID NOT NULL,
category_id UUID NOT NULL,
category_name VARCHAR(100),
creation_timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (jar_id) REFERENCES spending_jars (id)
)
;
Expand Down
25 changes: 25 additions & 0 deletions server/src/main/resources/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,28 @@ components:
example: MDL
pattern: '^[A-Z]{3}$'
description: The ISO 4217 currency code in uppercase (e.g., USD, EUR).

JarExpenseCategoryResource:
type: object
properties:
id:
type: integer
format: int64
example: 10
readOnly: true
description: The ID of the expense category.
categoryId:
type: string
format: uuid
readOnly: true
description: Unique identifier for the expense category.
categoryName:
type: string
readOnly: true
example: Clothing
description: The expense category name, it can be null.
creationTimestamp:
type: string
readOnly: true
example: 2024-03-23T10:00:04.224870Z
description: The timestamp when the expense category was created (read-only).
21 changes: 17 additions & 4 deletions server/src/test/groovy/io/myfinbox/spendingplan/DataSamples.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ class DataSamples {
]

static JAR_CATEGORY_TO_ADD_OR_REMOVE = [
categoryId: jarCategoryId,
categoryId : jarCategoryId,
categoryName: categoryName,
toAdd : true
toAdd : true
]

static EXPENSE_MODIFICATION_RECORD = [
Expand Down Expand Up @@ -158,8 +158,17 @@ class DataSamples {
]

static JAR_EXPENSE_CATEGORY = [
id : 1,
categoryId: [id: jarCategoryId]
id : 1,
categoryId : [id: jarCategoryId],
categoryName : categoryName,
creationTimestamp: timestamp,
]

static JAR_EXPENSE_CATEGORY_PLAIN = [
id : 1,
categoryId : jarCategoryId,
categoryName : categoryName,
creationTimestamp: timestamp,
]

static JAR_CATEGORIES_RESOURCE = [
Expand Down Expand Up @@ -258,4 +267,8 @@ class DataSamples {
static newSampleListJarAsString() {
JsonOutput.toJson([JAR_PLAIN])
}

static newSampleListJarExpenseCategoriesAsString() {
JsonOutput.toJson([JAR_EXPENSE_CATEGORY_PLAIN, JAR_EXPENSE_CATEGORY_PLAIN + [id: 2, categoryId: jarCategoryId2]])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,18 @@ class JarControllerSpec extends Specification {
JSONAssert.assertEquals(newSampleListJarAsString(), response.getBody(), LENIENT)
}

@Sql(['/spendingplan/web/plan-create.sql', '/spendingplan/web/jars-create.sql', '/spendingplan/web/jar_expense_category-create.sql'])
def "should get a list of expense categories"() {
when: 'listing expense categories for a planId by jarId'
def response = listJarExpenseCategories(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(newSampleListJarExpenseCategoriesAsString(), response.getBody(), LENIENT)
}

def postJar(String req) {
restTemplate.postForEntity("/v1/plans/${planId}/jars", entityRequest(req), String.class)
}
Expand Down Expand Up @@ -212,6 +224,19 @@ class JarControllerSpec extends Specification {
)
}

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

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

def entityRequest(String req) {
var headers = new HttpHeaders()
headers.setContentType(APPLICATION_JSON)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,7 @@ class AddOrRemoveJarCategoryServiceSpec extends Specification {
categories | failMessage
null | 'At least one category must be provided.'
[] | 'At least one category must be provided.'
[newSampleJarCategoryToAddAsMap(categoryId: null)] | 'Null categoryId or blank categoryName not allowed.'
[newSampleJarCategoryToAddAsMap(categoryName: null)] | 'Null categoryId or blank categoryName not allowed.'
[newSampleJarCategoryToAddAsMap(categoryName: '')] | 'Null categoryId or blank categoryName not allowed.'
[newSampleJarCategoryToAddAsMap(categoryId: null)] | 'Null categoryId not allowed.'
[newSampleJarCategoryToAddAsMap(), newSampleJarCategoryToAddAsMap()] | 'Duplicate category ids provided.'
}

Expand Down
Loading

0 comments on commit b2410c3

Please sign in to comment.