Add school management REST API and architecture checks

This commit is contained in:
2026-10-27 15:02:58 +01:00
parent 2bb3beef60
commit 777d9f199d
28 changed files with 987 additions and 15 deletions

25
CHANGELOG.md Normal file
View File

@@ -0,0 +1,25 @@
# Changelog
## [1.0] - 2026-10-27
### Added
- Quarkus backend running on Java 25, with health endpoints and Docker support.
- Domain model for dance schools, dancers, dance groups, coaches, shows and day parts.
- REST API to create, list, retrieve, update and delete dance schools, dancers, dance groups and shows.
- Membership management allowing a dancer to belong to multiple groups within a school.
- Shows with a name, date and three day parts: morning, afternoon and evening.
- Input validation, school-scoped resource access and cleanup of memberships when dancers are deleted.
- Shared in-memory storage for the REST API.
- Separate API model records in `rest.apimodel`.
- ArchUnit checks for package dependencies, domain independence, package cycles and API model conventions.
- HTTP integration tests for resource management, memberships, validation and school isolation.
### Fixed
- Adding entities to initially empty dance school and dance group collections now retains them.
### Limitations
- Data is lost when the application stops.
- Assigning dance groups to show day parts is not yet available.

107
README.md
View File

@@ -42,5 +42,108 @@ curl --fail http://localhost:8080/q/health/ready
Quarkus configuration can be overridden with environment variables, for example
`docker run --rm -e QUARKUS_HTTP_PORT=9090 -p 9090:9090 agiledancers`.
The domain services and repositories retain their existing in-memory behavior;
data is lost when the process stops. Business REST endpoints have not yet been added.
The REST API uses shared in-memory storage; data is lost when the process stops.
Architecture checks run with the normal test suite, or separately with:
```sh
mvn -pl agiledancers-be -Dtest=ArchitectureTest test
```
`ArchitectureTest` guards the following packages below `nl.agilecc.agiledancers`:
| Package | Responsibility | Allowed dependencies within the application |
| --- | --- | --- |
| `model` | Domain entities and specifications | Model |
| `service` | Application use cases and future ports | Model, repositories |
| `repository` | Current in-memory persistence | Model |
| `rest` | Future REST services | Services, model |
| `solver` | Future OR-Tools calculations | Services (ports), model |
REST and solver packages are optional until implemented. OR-Tools dependencies
are allowed only in `solver`; JAX-RS and RESTEasy dependencies only in `rest`.
The model may depend only on itself, Java and Lombok. Package cycles and
production classes outside these packages fail the architecture tests.
API models live in `rest.apimodel`, each in its own Java file as a public top-level
class or record. Their names end in `Request`, `Response`, `View`, `Dto` or `DTO`.
Architecture tests enforce this naming and location convention and require all
records in the REST layer to reside in `rest.apimodel`.
The current services instantiate concrete repositories, so this is not yet strict
Onion architecture. That requires repository interfaces in the core and injected
implementations in an outer adapter. For future calculations, define a port in
`service.port` using domain types and implement it in `solver`; services must not
depend on solver implementations or expose OR-Tools types.
## School management REST API
All endpoints consume and produce JSON. Resources are scoped to their dance school.
A dancer can belong to multiple dance groups within the same school. Memberships
are managed separately from group details and survive updates to dancers and groups.
| Resource collection | Item URL |
| --- | --- |
| `/api/dance-schools` | `/api/dance-schools/{schoolId}` |
| `/api/dance-schools/{schoolId}/dancers` | `.../dancers/{dancerId}` |
| `/api/dance-schools/{schoolId}/dance-groups` | `.../dance-groups/{groupId}` |
| `/api/dance-schools/{schoolId}/shows` | `.../shows/{showId}` |
Each collection supports `GET` (list) and `POST` (create). Each item supports `GET`,
`PUT` (replace editable details), and `DELETE`. Creation returns `201` with a
`Location` header; updates return `200`; deletion returns `204`. Invalid input
returns `400`, and missing resources or resources belonging to another school
return `404`. Client-supplied IDs are not used.
Request bodies (dates use ISO `yyyy-MM-dd`):
```json
{"name": "24 Dance Studio"}
```
```json
{"name": "Sam", "birthDate": "2010-05-12"}
```
```json
{
"category": "Teens",
"type": "REGULAR",
"startAge": 12,
"endAge": 18,
"styles": ["HIPHOP", "JAZZ"]
}
```
```json
{"name": "Annual show", "date": "2027-06-12"}
```
Names and categories must be nonblank. Birth dates cannot be in the future.
Both ages must be nonnegative and `startAge <= endAge`; `styles` is required
and may be empty. Group types are `SHOW_CASE`, `PRE_EDUCATION`, `SELECTION`,
`REGULAR`, `PRE_SCHOOLER`. Styles are `HIPHOP`, `JAZZ`, `MODERN`, `STREETDANCE`,
`CLASSIC`, `BREAKDANCE`.
School responses include `dancerIds`, `danceGroupIds`, and `showIds`; group
responses include `dancerIds`. To add or remove a dancer's group membership:
```sh
curl -X PUT http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
curl -X DELETE http://localhost:8080/api/dance-schools/0/dance-groups/0/dancers/0
```
Both operations return `204` and are idempotent for existing groups and dancers.
Deleting a dancer removes all their memberships; deleting a group retains the
dancers; deleting a school also deletes its dancers, groups and shows.
Shows have a name, a date and three automatically created day parts: `MORNING`,
`AFTERNOON`, `EVENING`. Each day part has an empty `danceGroupIds` list; assigning
groups is not exposed yet. Multiple shows per year are allowed.
Run the HTTP integration tests with:
```sh
mvn -pl agiledancers-be -Dtest=SchoolManagementResourceTest test
```

View File

@@ -14,6 +14,14 @@
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
@@ -24,6 +32,11 @@
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId>

View File

@@ -5,6 +5,7 @@ import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import java.util.Set;
import java.util.LinkedHashSet;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@@ -12,17 +13,23 @@ import java.util.Set;
@ToString(onlyExplicitlyIncluded = true)
public class DanceGroup extends AbstractEntity {
@ToString.Include
private final String category;
@Setter
private String category;
@ToString.Include
private final DanceGroupType type;
@Setter
private DanceGroupType type;
@ToString.Include
private final int startAge;
@Setter
private int startAge;
@ToString.Include
private final int endAge;
@Setter
private int endAge;
@ToString.Include
private Set<Dancer> dancers;
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();
@ToString.Include
private Set<DanceGroupStyle> styles;
@Builder.Default
private final Set<DanceGroupStyle> styles = new LinkedHashSet<>();
@ToString.Include
private Coach coach;
@@ -41,4 +48,13 @@ public class DanceGroup extends AbstractEntity {
public void addStyle(DanceGroupStyle style) {
super.addNullSafe(styles, style);
}
public void removeDancer(Dancer dancer) {
dancers.remove(dancer);
}
public void replaceStyles(Set<DanceGroupStyle> styles) {
this.styles.clear();
this.styles.addAll(styles);
}
}

View File

@@ -2,19 +2,24 @@ package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.Collections;
import java.util.Set;
import java.util.LinkedHashSet;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class DanceSchool extends AbstractEntity {
private final String name;
private final Set<Dancer> dancers;
private final Set<DanceGroup> danceGroups;
private final Set<Show> shows;
private final Set<Coach> coaches;
@Setter
private String name;
@Builder.Default
private final Set<Dancer> dancers = new LinkedHashSet<>();
@Builder.Default
private final Set<DanceGroup> danceGroups = new LinkedHashSet<>();
@Builder.Default
private final Set<Show> shows = new LinkedHashSet<>();
@Builder.Default
private final Set<Coach> coaches = new LinkedHashSet<>();
public Set<Dancer> getDancers() {
return super.unmodifiableNullSafe(dancers);
@@ -47,4 +52,16 @@ public class DanceSchool extends AbstractEntity {
public void addCoach(Coach coach) {
super.addNullSafe(coaches, coach);
}
public void removeDancer(Dancer entity) {
dancers.remove(entity);
}
public void removeDanceGroup(DanceGroup entity) {
danceGroups.remove(entity);
}
public void removeShow(Show entity) {
shows.remove(entity);
}
}

View File

@@ -11,8 +11,10 @@ import java.time.Period;
@ToString(onlyExplicitlyIncluded = true)
public class Dancer extends AbstractEntity {
@ToString.Include
private final String name;
@Setter
private String name;
@ToString.Include
@Setter
private LocalDate birthDate;
public int age() {

View File

@@ -3,12 +3,17 @@ package nl.agilecc.agiledancers.model;
import lombok.*;
import java.util.List;
import java.time.LocalDate;
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString(onlyExplicitlyIncluded = true)
public class Show extends AbstractEntity {
@Setter
private String name;
@Setter
private LocalDate date;
@ToString.Include
private final List<DayPart> dayParts;
}

View File

@@ -24,4 +24,7 @@ public abstract class AbstractRepository<T extends AbstractEntity> {
public T findById(long id) {
return entities.get(id);
}
public void delete(long id) {
entities.remove(id);
}
}

View File

@@ -0,0 +1,69 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.GroupRequest;
import nl.agilecc.agiledancers.rest.apimodel.GroupView;
@Path("/api/dance-schools/{schoolId}/dance-groups")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceGroupResource {
@Inject
SchoolManagementService service;
@PathParam("schoolId")
long schoolId;
@GET
public List<GroupView> list() {
return service.groups(schoolId).stream().map(GroupView::of).toList();
}
@GET
@Path("/{id}")
public GroupView get(@PathParam("id") long id) {
return GroupView.of(service.group(schoolId, id));
}
@POST
public Response create(@NotNull @Valid GroupRequest request, @Context UriInfo uriInfo) {
var entity = service.createGroup(schoolId, request.category(), request.type(), request.startAge(), request.endAge(), request.styles());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(GroupView.of(entity)).build();
}
@PUT
@Path("/{id}")
public GroupView update(@PathParam("id") long id, @NotNull @Valid GroupRequest request) {
return GroupView.of(service.updateGroup(schoolId, id, request.category(), request.type(), request.startAge(), request.endAge(), request.styles()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
service.deleteGroup(schoolId, id);
return Response.noContent().build();
}
@PUT
@Path("/{id}/dancers/{dancerId}")
public Response addDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
service.addGroupDancer(schoolId, id, dancerId);
return Response.noContent().build();
}
@DELETE
@Path("/{id}/dancers/{dancerId}")
public Response removeDancer(@PathParam("id") long id, @PathParam("dancerId") long dancerId) {
service.removeGroupDancer(schoolId, id, dancerId);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,52 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.SchoolRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchoolView;
@Path("/api/dance-schools")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DanceSchoolResource {
@Inject
SchoolManagementService service;
@GET
public List<SchoolView> list() {
return service.schools().stream().map(SchoolView::of).toList();
}
@GET
@Path("/{id}")
public SchoolView get(@PathParam("id") long id) {
return SchoolView.of(service.school(id));
}
@POST
public Response create(@NotNull @Valid SchoolRequest request, @Context UriInfo uriInfo) {
var entity = service.createSchool(request.name());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(SchoolView.of(entity)).build();
}
@PUT
@Path("/{id}")
public SchoolView update(@PathParam("id") long id, @NotNull @Valid SchoolRequest request) {
return SchoolView.of(service.updateSchool(id, request.name()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
service.deleteSchool(id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,55 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.DancerRequest;
import nl.agilecc.agiledancers.rest.apimodel.DancerView;
@Path("/api/dance-schools/{schoolId}/dancers")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class DancerResource {
@Inject
SchoolManagementService service;
@PathParam("schoolId")
long schoolId;
@GET
public List<DancerView> list() {
return service.dancers(schoolId).stream().map(DancerView::of).toList();
}
@GET
@Path("/{id}")
public DancerView get(@PathParam("id") long id) {
return DancerView.of(service.dancer(schoolId, id));
}
@POST
public Response create(@NotNull @Valid DancerRequest request, @Context UriInfo uriInfo) {
var entity = service.createDancer(schoolId, request.name(), request.birthDate());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(DancerView.of(entity)).build();
}
@PUT
@Path("/{id}")
public DancerView update(@PathParam("id") long id, @NotNull @Valid DancerRequest request) {
return DancerView.of(service.updateDancer(schoolId, id, request.name(), request.birthDate()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
service.deleteDancer(schoolId, id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,21 @@
package nl.agilecc.agiledancers.rest;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.rest.apimodel.ErrorView;
@Provider
public class ManagementExceptionMapper implements ExceptionMapper<ManagementException> {
@Override
public Response toResponse(ManagementException exception) {
int status = switch (exception.getKind()) {
case NOT_FOUND -> 404;
case INVALID_INPUT -> 400;
};
return Response.status(status).type(MediaType.APPLICATION_JSON_TYPE)
.entity(new ErrorView(exception.getMessage())).build();
}
}

View File

@@ -0,0 +1,55 @@
package nl.agilecc.agiledancers.rest;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import java.util.List;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
@Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ShowResource {
@Inject
SchoolManagementService service;
@PathParam("schoolId")
long schoolId;
@GET
public List<ShowView> list() {
return service.shows(schoolId).stream().map(ShowView::of).toList();
}
@GET
@Path("/{id}")
public ShowView get(@PathParam("id") long id) {
return ShowView.of(service.show(schoolId, id));
}
@POST
public Response create(@NotNull @Valid ShowRequest request, @Context UriInfo uriInfo) {
var entity = service.createShow(schoolId, request.name(), request.date());
return Response.created(uriInfo.getAbsolutePathBuilder().path(entity.getId().toString()).build())
.entity(ShowView.of(entity)).build();
}
@PUT
@Path("/{id}")
public ShowView update(@PathParam("id") long id, @NotNull @Valid ShowRequest request) {
return ShowView.of(service.updateShow(schoolId, id, request.name(), request.date()));
}
@DELETE
@Path("/{id}")
public Response delete(@PathParam("id") long id) {
service.deleteShow(schoolId, id);
return Response.noContent().build();
}
}

View File

@@ -0,0 +1,8 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import java.time.LocalDate;
public record DancerRequest(@NotBlank String name, @NotNull @PastOrPresent LocalDate birthDate) {}

View File

@@ -0,0 +1,10 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.Dancer;
import java.time.LocalDate;
public record DancerView(long id, String name, LocalDate birthDate) {
public static DancerView of(Dancer dancer) {
return new DancerView(dancer.getId(), dancer.getName(), dancer.getBirthDate());
}
}

View File

@@ -0,0 +1,6 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.specifications.DayPartType;
import java.util.List;
public record DayPartView(DayPartType type, List<Long> danceGroupIds) {}

View File

@@ -0,0 +1,6 @@
package nl.agilecc.agiledancers.rest.apimodel;
import io.quarkus.runtime.annotations.RegisterForReflection;
@RegisterForReflection
public record ErrorView(String message) {}

View File

@@ -0,0 +1,12 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import java.util.Set;
public record GroupRequest(@NotBlank String category, @NotNull DanceGroupType type,
@NotNull @Min(0) Integer startAge, @NotNull @Min(0) Integer endAge,
@NotNull Set<@NotNull DanceGroupStyle> styles) {}

View File

@@ -0,0 +1,17 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
import java.util.List;
import java.util.Set;
public record GroupView(long id, String category, DanceGroupType type, int startAge, int endAge,
Set<DanceGroupStyle> styles, List<Long> dancerIds) {
public static GroupView of(DanceGroup group) {
return new GroupView(group.getId(), group.getCategory(), group.getType(), group.getStartAge(),
group.getEndAge(), Set.copyOf(group.getStyles()),
group.getDancers().stream().map(Dancer::getId).toList());
}
}

View File

@@ -0,0 +1,5 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
public record SchoolRequest(@NotBlank String name) {}

View File

@@ -0,0 +1,17 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.DanceSchool;
import nl.agilecc.agiledancers.model.Dancer;
import nl.agilecc.agiledancers.model.DanceGroup;
import nl.agilecc.agiledancers.model.Show;
import java.util.List;
public record SchoolView(long id, String name, List<Long> dancerIds,
List<Long> danceGroupIds, List<Long> showIds) {
public static SchoolView of(DanceSchool school) {
return new SchoolView(school.getId(), school.getName(),
school.getDancers().stream().map(Dancer::getId).toList(),
school.getDanceGroups().stream().map(DanceGroup::getId).toList(),
school.getShows().stream().map(Show::getId).toList());
}
}

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.rest.apimodel;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
public record ShowRequest(@NotBlank String name, @NotNull LocalDate date) {}

View File

@@ -0,0 +1,14 @@
package nl.agilecc.agiledancers.rest.apimodel;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.DanceGroup;
import java.time.LocalDate;
import java.util.List;
public record ShowView(long id, String name, LocalDate date, List<DayPartView> dayParts) {
public static ShowView of(Show show) {
return new ShowView(show.getId(), show.getName(), show.getDate(), show.getDayParts().stream()
.map(dayPart -> new DayPartView(dayPart.getType(), dayPart.getDanceGroups().stream()
.map(DanceGroup::getId).toList())).toList());
}
}

View File

@@ -0,0 +1,16 @@
package nl.agilecc.agiledancers.service;
public class ManagementException extends RuntimeException {
public enum Kind { NOT_FOUND, INVALID_INPUT }
private final Kind kind;
public ManagementException(Kind kind, String message) {
super(message);
this.kind = kind;
}
public Kind getKind() {
return kind;
}
}

View File

@@ -0,0 +1,170 @@
package nl.agilecc.agiledancers.service;
import jakarta.inject.Singleton;
import nl.agilecc.agiledancers.model.*;
import nl.agilecc.agiledancers.model.specifications.*;
import nl.agilecc.agiledancers.repository.*;
import java.time.LocalDate;
import java.util.*;
import static nl.agilecc.agiledancers.service.ManagementException.Kind.*;
/** Owns the in-memory school aggregates; all writes go through this shared service. */
@Singleton
public class SchoolManagementService {
private final DanceSchoolRepository schools = new DanceSchoolRepository();
private final DancerRepository dancers = new DancerRepository();
private final DanceGroupRepository groups = new DanceGroupRepository();
private final ShowRepository shows = new ShowRepository();
public synchronized List<DanceSchool> schools() {
return schools.findAll();
}
public synchronized DanceSchool school(long schoolId) {
return required(schools.findById(schoolId), "Dance school", schoolId);
}
public synchronized DanceSchool createSchool(String name) {
return schools.add(DanceSchool.builder().name(name).build());
}
public synchronized DanceSchool updateSchool(long schoolId, String name) {
DanceSchool school = school(schoolId);
school.setName(name);
return school;
}
public synchronized void deleteSchool(long schoolId) {
DanceSchool school = school(schoolId);
school.getDanceGroups().forEach(group -> groups.delete(group.getId()));
school.getDancers().forEach(dancer -> dancers.delete(dancer.getId()));
school.getShows().forEach(show -> shows.delete(show.getId()));
schools.delete(schoolId);
}
public synchronized List<Dancer> dancers(long schoolId) {
return List.copyOf(school(schoolId).getDancers());
}
public synchronized Dancer dancer(long schoolId, long dancerId) {
return member(school(schoolId).getDancers(), dancerId, "Dancer");
}
public synchronized Dancer createDancer(long schoolId, String name, LocalDate birthDate) {
DanceSchool school = school(schoolId);
Dancer dancer = dancers.add(Dancer.builder().name(name).birthDate(birthDate).build());
school.addDancer(dancer);
return dancer;
}
public synchronized Dancer updateDancer(long schoolId, long dancerId, String name, LocalDate birthDate) {
Dancer dancer = dancer(schoolId, dancerId);
dancer.setName(name);
dancer.setBirthDate(birthDate);
return dancer;
}
public synchronized void deleteDancer(long schoolId, long dancerId) {
DanceSchool school = school(schoolId);
Dancer dancer = dancer(schoolId, dancerId);
school.getDanceGroups().forEach(group -> group.removeDancer(dancer));
school.removeDancer(dancer);
dancers.delete(dancerId);
}
public synchronized List<DanceGroup> groups(long schoolId) {
return List.copyOf(school(schoolId).getDanceGroups());
}
public synchronized DanceGroup group(long schoolId, long groupId) {
return member(school(schoolId).getDanceGroups(), groupId, "Dance group");
}
public synchronized DanceGroup createGroup(long schoolId, String category, DanceGroupType type,
int startAge, int endAge, Set<DanceGroupStyle> styles) {
DanceSchool school = school(schoolId);
validateAgeRange(startAge, endAge);
DanceGroup group = groups.add(DanceGroup.builder().category(category).type(type)
.startAge(startAge).endAge(endAge).styles(new LinkedHashSet<>(styles)).build());
school.addDanceGroup(group);
return group;
}
public synchronized DanceGroup updateGroup(long schoolId, long groupId, String category, DanceGroupType type,
int startAge, int endAge, Set<DanceGroupStyle> styles) {
DanceGroup group = group(schoolId, groupId);
validateAgeRange(startAge, endAge);
group.setCategory(category);
group.setType(type);
group.setStartAge(startAge);
group.setEndAge(endAge);
group.replaceStyles(styles);
return group;
}
public synchronized void deleteGroup(long schoolId, long groupId) {
DanceGroup group = group(schoolId, groupId);
school(schoolId).removeDanceGroup(group);
groups.delete(groupId);
}
public synchronized void addGroupDancer(long schoolId, long groupId, long dancerId) {
DanceGroup group = group(schoolId, groupId);
group.addDancer(dancer(schoolId, dancerId));
}
public synchronized void removeGroupDancer(long schoolId, long groupId, long dancerId) {
DanceGroup group = group(schoolId, groupId);
group.removeDancer(dancer(schoolId, dancerId));
}
public synchronized List<Show> shows(long schoolId) {
return List.copyOf(school(schoolId).getShows());
}
public synchronized Show show(long schoolId, long showId) {
return member(school(schoolId).getShows(), showId, "Show");
}
public synchronized Show createShow(long schoolId, String name, LocalDate date) {
DanceSchool school = school(schoolId);
List<DayPart> dayParts = Arrays.stream(DayPartType.values())
.map(type -> DayPart.builder().type(type).danceGroups(List.of()).build()).toList();
Show show = shows.add(Show.builder().name(name).date(date).dayParts(dayParts).build());
school.addShow(show);
return show;
}
public synchronized Show updateShow(long schoolId, long showId, String name, LocalDate date) {
Show show = show(schoolId, showId);
show.setName(name);
show.setDate(date);
return show;
}
public synchronized void deleteShow(long schoolId, long showId) {
Show show = show(schoolId, showId);
school(schoolId).removeShow(show);
shows.delete(showId);
}
private static void validateAgeRange(int startAge, int endAge) {
if (startAge > endAge) {
throw new ManagementException(INVALID_INPUT, "startAge must not exceed endAge");
}
}
private static <T extends AbstractEntity> T member(Collection<T> entities, long id, String type) {
return entities.stream().filter(entity -> entity.getId() == id).findFirst()
.orElseThrow(() -> new ManagementException(NOT_FOUND, type + " " + id + " not found in this school"));
}
private static <T> T required(T value, String type, long id) {
if (value == null) {
throw new ManagementException(NOT_FOUND, type + " " + id + " not found");
}
return value;
}
}

View File

@@ -0,0 +1,77 @@
package nl.agilecc.agiledancers;
import com.tngtech.archunit.core.importer.ImportOption;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;
import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices;
@AnalyzeClasses(packages = "nl.agilecc.agiledancers", importOptions = ImportOption.DoNotIncludeTests.class)
class ArchitectureTest {
private static final String ROOT = "nl.agilecc.agiledancers";
private static final String API_MODEL = ROOT + ".rest.apimodel";
// API model classes and records use the Request, Response, View, Dto or DTO suffix.
@ArchTest
static final ArchRule api_models_reside_in_the_api_model_package = classes()
.that().haveNameMatching(".*(Request|Response|View|Dto|DTO)$")
.should().resideInAPackage(API_MODEL);
@ArchTest
static final ArchRule rest_records_reside_in_the_api_model_package = classes()
.that().resideInAPackage(ROOT + ".rest..")
.and().areRecords()
.should().resideInAPackage(API_MODEL);
@ArchTest
static final ArchRule api_models_are_public_top_level_types = classes()
.that().resideInAPackage(API_MODEL + "..")
.should().bePublic()
.andShould().beTopLevelClasses()
.andShould().haveNameMatching(".*(Request|Response|View|Dto|DTO)$");
// The existing services depend on concrete repositories. This rule preserves that
// arrangement until repository ports and dependency injection are introduced.
@ArchTest
static final ArchRule layer_dependencies = layeredArchitecture()
.consideringOnlyDependenciesInLayers()
.layer("Model").definedBy(ROOT + ".model..")
.layer("Services").definedBy(ROOT + ".service..")
.layer("Repositories").definedBy(ROOT + ".repository..")
.optionalLayer("REST").definedBy(ROOT + ".rest..")
.optionalLayer("Solver").definedBy(ROOT + ".solver..")
.whereLayer("Model").mayNotAccessAnyLayer()
.whereLayer("Services").mayOnlyAccessLayers("Model", "Repositories")
.whereLayer("Repositories").mayOnlyAccessLayers("Model")
.whereLayer("REST").mayOnlyAccessLayers("Services", "Model")
.whereLayer("Solver").mayOnlyAccessLayers("Services", "Model");
@ArchTest
static final ArchRule production_classes_have_an_architectural_home = classes()
.should().resideInAnyPackage(ROOT + ".model..", ROOT + ".service..",
ROOT + ".repository..", ROOT + ".rest..", ROOT + ".solver..");
@ArchTest
static final ArchRule model_is_independent_of_frameworks = classes()
.that().resideInAPackage(ROOT + ".model..")
.should().onlyDependOnClassesThat()
.resideInAnyPackage(ROOT + ".model..", "java..", "lombok..");
@ArchTest
static final ArchRule ortools_is_encapsulated_in_solver = noClasses()
.that().resideOutsideOfPackage(ROOT + ".solver..")
.should().dependOnClassesThat().resideInAPackage("com.google.ortools..");
@ArchTest
static final ArchRule http_api_is_encapsulated_in_rest = noClasses()
.that().resideOutsideOfPackage(ROOT + ".rest..")
.should().dependOnClassesThat().resideInAnyPackage("jakarta.ws.rs..", "org.jboss.resteasy..");
@ArchTest
static final ArchRule packages_are_free_of_cycles = slices()
.matching(ROOT + ".(*)..").should().beFreeOfCycles();
}

View File

@@ -0,0 +1,164 @@
package nl.agilecc.agiledancers;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.response.ValidatableResponse;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@QuarkusTest
class SchoolManagementResourceTest {
private static final String SCHOOLS = "/api/dance-schools";
private static final Map<String, Object> DANCER = Map.of("name", "Sam", "birthDate", "2010-05-12");
private static final Map<String, Object> GROUP = Map.of("category", "Teens", "type", "REGULAR",
"startAge", 12, "endAge", 18, "styles", new String[]{"HIPHOP"});
private static final Map<String, Object> SHOW = Map.of("name", "Annual show", "date", "2027-06-12");
@Test
void managesSchoolAndItsResources() {
long schoolId = create(SCHOOLS, Map.of("name", "Dance Studio"));
String school = SCHOOLS + "/" + schoolId;
try {
given().get(school).then().statusCode(200).body("name", equalTo("Dance Studio"));
given().get(SCHOOLS).then().statusCode(200).body("id", hasItem((int) schoolId));
put(school, Map.of("name", "Renamed studio")).statusCode(200).body("name", equalTo("Renamed studio"));
long dancerId = create(school + "/dancers", DANCER);
String dancer = school + "/dancers/" + dancerId;
put(dancer, Map.of("name", "Sam updated", "birthDate", "2011-06-13"))
.statusCode(200).body("birthDate", equalTo("2011-06-13"));
given().get(dancer).then().statusCode(200).body("name", equalTo("Sam updated"));
given().get(school + "/dancers").then().statusCode(200).body("id", contains((int) dancerId));
long groupId = create(school + "/dance-groups", GROUP);
String group = school + "/dance-groups/" + groupId;
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
put(group, Map.of("category", "Selection", "type", "SELECTION", "startAge", 10,
"endAge", 20, "styles", new String[]{"JAZZ", "MODERN"}))
.statusCode(200).body("category", equalTo("Selection"))
.body("dancerIds", contains((int) dancerId)).body("styles", containsInAnyOrder("JAZZ", "MODERN"));
given().get(group).then().statusCode(200).body("type", equalTo("SELECTION"));
given().get(school + "/dance-groups").then().statusCode(200).body("id", contains((int) groupId));
long showId = create(school + "/shows", SHOW);
String show = school + "/shows/" + showId;
given().get(show).then().statusCode(200)
.body("dayParts.type", contains("MORNING", "AFTERNOON", "EVENING"))
.body("dayParts.danceGroupIds", everyItem(empty()));
put(show, Map.of("name", "Summer show", "date", "2027-07-01"))
.statusCode(200).body("date", equalTo("2027-07-01"));
given().get(school + "/shows").then().statusCode(200).body("id", contains((int) showId));
given().get(school).then().statusCode(200).body("dancerIds", contains((int) dancerId))
.body("danceGroupIds", contains((int) groupId)).body("showIds", contains((int) showId));
for (String path : new String[]{dancer, group, show}) {
given().delete(path).then().statusCode(204);
given().get(path).then().statusCode(404);
given().delete(path).then().statusCode(404);
}
given().get(school).then().body("dancerIds", empty()).body("danceGroupIds", empty()).body("showIds", empty());
} finally {
given().delete(school).then().statusCode(204);
}
given().get(school).then().statusCode(404);
}
@Test
void dancerCanBelongToMultipleGroupsAndDeletingDancerRemovesMemberships() {
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Membership school"));
try {
long dancerId = create(school + "/dancers", DANCER);
String first = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
String second = school + "/dance-groups/" + create(school + "/dance-groups", GROUP);
for (String group : new String[]{first, second}) {
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
given().put(group + "/dancers/" + dancerId).then().statusCode(204);
given().get(group).then().body("dancerIds", contains((int) dancerId));
}
given().delete(first + "/dancers/" + dancerId).then().statusCode(204);
given().delete(first + "/dancers/" + dancerId).then().statusCode(204);
given().get(first).then().body("dancerIds", empty());
given().get(second).then().body("dancerIds", contains((int) dancerId));
given().put(first + "/dancers/" + dancerId).then().statusCode(204);
given().delete(school + "/dancers/" + dancerId).then().statusCode(204);
given().get(first).then().body("dancerIds", empty());
given().get(second).then().body("dancerIds", empty());
} finally {
given().delete(school).then().statusCode(204);
}
}
@Test
void isolatesSchoolsAndCascadesSchoolDeletion() {
String first = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "First"));
String second = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Second"));
try {
long dancer = create(first + "/dancers", DANCER);
long group = create(first + "/dance-groups", GROUP);
long show = create(first + "/shows", SHOW);
long otherGroup = create(second + "/dance-groups", GROUP);
for (String path : new String[]{"/dancers/" + dancer, "/dance-groups/" + group, "/shows/" + show}) {
given().get(second + path).then().statusCode(404);
given().delete(second + path).then().statusCode(404);
}
put(second + "/dancers/" + dancer, DANCER).statusCode(404);
put(second + "/dance-groups/" + group, GROUP).statusCode(404);
put(second + "/shows/" + show, SHOW).statusCode(404);
given().put(second + "/dance-groups/" + otherGroup + "/dancers/" + dancer).then().statusCode(404);
given().get(second + "/dance-groups/" + otherGroup).then().body("dancerIds", empty());
given().delete(first).then().statusCode(204);
given().get(first + "/dancers").then().statusCode(404);
given().get(first + "/dance-groups").then().statusCode(404);
given().get(first + "/shows").then().statusCode(404);
given().get(second).then().statusCode(200);
} finally {
given().delete(first);
given().delete(second).then().statusCode(204);
}
}
@Test
void rejectsInvalidInputWithoutChangingStoredData() {
given().contentType("application/json").body("{}").post(SCHOOLS).then().statusCode(400);
given().contentType("application/json").body("null").post(SCHOOLS).then().statusCode(400);
String school = SCHOOLS + "/" + create(SCHOOLS, Map.of("name", "Validation school"));
try {
put(school, Map.of("name", " ")).statusCode(400);
given().get(school).then().body("name", equalTo("Validation school"));
for (Object body : new Object[]{Map.of("name", "Sam"), Map.of("name", "Sam", "birthDate", "2999-01-01"),
Map.of("name", "Sam", "birthDate", "not-a-date")}) {
given().contentType("application/json").body(body).post(school + "/dancers").then().statusCode(400);
}
for (String body : new String[]{"{}", "{\"category\":\"Teens\",\"type\":\"INVALID\"}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":-1,\"endAge\":18,\"styles\":[]}",
"{\"category\":\"Teens\",\"type\":\"REGULAR\",\"startAge\":18,\"endAge\":12,\"styles\":[]}"}) {
given().contentType("application/json").body(body).post(school + "/dance-groups").then().statusCode(400);
}
long group = create(school + "/dance-groups", GROUP);
put(school + "/dance-groups/" + group, Map.of("category", "Invalid", "type", "REGULAR",
"startAge", 20, "endAge", 10, "styles", new String[]{})).statusCode(400);
given().get(school + "/dance-groups/" + group).then().body("category", equalTo("Teens"));
given().contentType("application/json").body(Map.of("name", "Show"))
.post(school + "/shows").then().statusCode(400);
given().get(school + "/dancers").then().body("$", empty());
given().get(school + "/shows").then().body("$", empty());
given().contentType("application/json").body(DANCER)
.post(SCHOOLS + "/9223372036854775807/dancers").then().statusCode(404);
} finally {
given().delete(school).then().statusCode(204);
}
}
private static long create(String path, Object body) {
return given().contentType("application/json").body(body).post(path).then()
.statusCode(201).header("Location", matchesPattern(".*/[0-9]+$"))
.extract().jsonPath().getLong("id");
}
private static ValidatableResponse put(String path, Object body) {
return given().contentType("application/json").body(body).put(path).then();
}
}

View File

@@ -28,6 +28,7 @@
<lombok.version>1.18.48</lombok.version>
<!-- test dependencies -->
<archunit.version>1.5.1</archunit.version>
<datafaker.version>2.7.0</datafaker.version>
</properties>
@@ -48,6 +49,12 @@
</dependency>
<!-- test dependencies -->
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>${archunit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.datafaker</groupId>
<artifactId>datafaker</artifactId>