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 jar expense category query api #44

Merged
merged 1 commit into from
Aug 17, 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,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
Loading