Skip to content
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ Java 25+ (Spring Boot 4)
## Database
Database uses in-memory DB H2. It will automatically build and export the required tables and data.

The H2 console (`/h2-console`, JDBC URL `jdbc:h2:mem:test`) is available while running with devtools, e.g. `./mvnw spring-boot:run`. It is not served by the packaged jar.

## Testing
There are scripts to test the Rest API in Postman.
4 changes: 0 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.company.resourceapi.config;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

/**
* Populates the {@code @CreatedDate}/{@code @LastModifiedDate} fields of the entities.
* Kept out of the application class so web slice tests don't need a JPA context.
*/
@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
public class JpaAuditingConfig {

/** The audit columns are {@code datetime}, so keep responses consistent with what is stored. */
@Bean
DateTimeProvider auditingDateTimeProvider() {
return () -> Optional.of(Instant.now().truncatedTo(ChronoUnit.SECONDS));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,36 @@
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
import static org.springframework.http.HttpStatus.NOT_FOUND;

import com.company.resourceapi.entities.Project;
import com.company.resourceapi.entities.SdlcSystem;
import com.company.resourceapi.exceptions.ConflictException;
import com.company.resourceapi.exceptions.NotFoundException;
import com.company.resourceapi.services.ProjectService;
import com.company.resourceapi.services.SdlcSystemService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.ConstraintViolationException;
import jakarta.validation.Valid;
import java.net.URI;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.exception.ConstraintViolationException.ConstraintKind;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping(ProjectRestController.ENDPOINT)
Expand All @@ -50,29 +55,23 @@ public class ProjectRestController {
@GetMapping(ENDPOINT_ID)
public ResponseEntity<Project> getProject(
@Parameter(name = PATH_VARIABLE_ID, description = API_PARAM_ID, required = true)
@PathVariable(PATH_VARIABLE_ID) final long projectId) {
try {
return ResponseEntity.status(OK).body(projectService.getProject(projectId));
} catch (NotFoundException e) {
log.debug(e.getMessage());
return ResponseEntity.status(NOT_FOUND).build();
}
@PathVariable(PATH_VARIABLE_ID) final long projectId) throws NotFoundException {
return ResponseEntity.ok(projectService.getProject(projectId));
}

@Operation(summary = "Create a Project")
@PostMapping
public ResponseEntity<Project> createProject(
@Valid
@RequestBody Project project) {
long systemId = project.getSdlcSystem().getId();
@RequestBody Project project) throws NotFoundException {
var sdlcSystem = findSdlcSystem(project.getSdlcSystem());

if (sdlcSystemService.getSdlcSystem(systemId).isEmpty()) {
return ResponseEntity.status(NOT_FOUND).build();
}
if (projectService.getProject(project.getExternalId(), systemId).isPresent()) {
// Checked up front for a clean 409; the unique key still guards against concurrent requests
if (projectService.getProject(project.getExternalId(), sdlcSystem.getId()).isPresent()) {
return ResponseEntity.status(CONFLICT).build();
}

project.setSdlcSystem(sdlcSystem);
var createdProject = projectService.createProject(project);
return ResponseEntity.status(CREATED)
.location(URI.create(LOCATION_HEADER.formatted(createdProject.getId())))
Expand All @@ -84,11 +83,8 @@ public ResponseEntity<Project> createProject(
public ResponseEntity<Project> updateProject(
@Parameter(name = PATH_VARIABLE_ID, description = API_PARAM_ID, required = true)
@PathVariable(PATH_VARIABLE_ID) long projectId,
@Valid @RequestBody PatchedProject patchedProject) {

try {
var project = projectService.getProject(projectId);

@RequestBody PatchedProject patchedProject) throws NotFoundException {
var updatedProject = projectService.updateProject(projectId, project -> {
if (patchedProject.isNameDirty()) {
project.setName(patchedProject.getName());
}
Expand All @@ -98,28 +94,61 @@ public ResponseEntity<Project> updateProject(
}

if (patchedProject.isSdlcSystemDirty()) {
long systemId = patchedProject.getSdlcSystem().getId();
if (sdlcSystemService.getSdlcSystem(systemId).isPresent()) {
project.setSdlcSystem(patchedProject.getSdlcSystem());
} else {
throw new NotFoundException(SdlcSystem.class, systemId);
}
var patchedSystem = patchedProject.getSdlcSystem();
project.setSdlcSystem(patchedSystem == null ? null : findSdlcSystem(patchedSystem));
}
});

return ResponseEntity.ok(updatedProject);
}

@ExceptionHandler(ConstraintViolationException.class)
ResponseEntity<Void> handleConstraintViolation(ConstraintViolationException e) {
log.debug(e.getMessage());
return ResponseEntity.status(BAD_REQUEST).build();
}

@ExceptionHandler(NotFoundException.class)
ResponseEntity<Void> handleNotFound(NotFoundException e) {
log.debug(e.getMessage());
return ResponseEntity.status(NOT_FOUND).build();
}

@ExceptionHandler(ConflictException.class)
ResponseEntity<Void> handleConflict(ConflictException e) {
log.debug(e.getMessage());
return ResponseEntity.status(CONFLICT).build();
}

if ((patchedProject.isExternalIdDirty() || patchedProject.isSdlcSystemDirty())
&& projectService
.getProject(project.getExternalId(), project.getSdlcSystem().getId())
.isPresent()) {
return ResponseEntity.status(CONFLICT).build();
/**
* The (sdlcSystem, externalId) pair is unique, so a concurrent request can still insert a
* duplicate after the conflict checks have passed. Any other integrity violation is a
* server-side problem and is left to the default error handling.
*/
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<Void> handleDataIntegrityViolation(DataIntegrityViolationException e) {
if (!isUniqueConstraintViolation(e)) {
throw e;
}
log.debug("Rejected duplicate project: {}", e.getMostSpecificCause().getMessage());
return ResponseEntity.status(CONFLICT).build();
}

static boolean isUniqueConstraintViolation(DataIntegrityViolationException e) {
for (Throwable cause = e.getCause(); cause != null; cause = cause.getCause()) {
if (cause instanceof org.hibernate.exception.ConstraintViolationException violation) {
return violation.getKind() == ConstraintKind.UNIQUE;
}
}
return false;
}

return projectService.updateProject(project)
.map(resultantProject -> ResponseEntity.status(OK).body(resultantProject))
.orElseGet(() -> ResponseEntity.status(BAD_REQUEST).build());
} catch (NotFoundException e) {
log.debug(e.getMessage());
return ResponseEntity.status(NOT_FOUND).build();
/** Resolves the SDLC system a request refers to; an absent id deserializes to 0. */
private SdlcSystem findSdlcSystem(SdlcSystem reference) throws NotFoundException {
if (reference.getId() == 0) {
throw new ResponseStatusException(BAD_REQUEST, "sdlcSystem.id is required");
}
return sdlcSystemService.getSdlcSystem(reference.getId());
}

@Getter
Expand All @@ -133,10 +162,8 @@ static class PatchedProject {
private boolean isSdlcSystemDirty = false;

public void setExternalId(String externalId) {
if (externalId != null) {
this.externalId = externalId;
isExternalIdDirty = true;
}
this.externalId = externalId;
isExternalIdDirty = true;
}

public void setName(String name) {
Expand Down
18 changes: 16 additions & 2 deletions src/main/java/com/company/resourceapi/entities/Project.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.company.resourceapi.entities;

import static com.fasterxml.jackson.annotation.JsonProperty.Access.READ_ONLY;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
Expand All @@ -11,9 +14,11 @@
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.DynamicUpdate;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
Expand All @@ -23,17 +28,24 @@
@EntityListeners(AuditingEntityListener.class)
@Data
@NoArgsConstructor
@DynamicUpdate
public class Project {

/** Length of the varchar columns in schema.sql. */
public static final int MAX_TEXT_LENGTH = 255;

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonProperty(access = READ_ONLY)
private long id;

@Column(name = "external_id", nullable = false)
@NotBlank
@Size(max = MAX_TEXT_LENGTH)
private String externalId;

@Column(name = "name")
@Size(max = MAX_TEXT_LENGTH)
private String name;

@ManyToOne
Expand All @@ -43,9 +55,11 @@ public class Project {

@Column(name = "created_date", nullable = false)
@CreatedDate
private Instant createdDate = Instant.now();
@JsonProperty(access = READ_ONLY)
private Instant createdDate;

@Column(name = "last_modified_date", nullable = false)
@LastModifiedDate
private Instant lastModifiedDate = Instant.now();
@JsonProperty(access = READ_ONLY)
private Instant lastModifiedDate;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.company.resourceapi.exceptions;

public class ConflictException extends RuntimeException {

public ConflictException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
package com.company.resourceapi.repositories;

import com.company.resourceapi.entities.Project;
import jakarta.persistence.QueryHint;
import java.util.Optional;
import org.hibernate.jpa.HibernateHints;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.QueryHints;
import org.springframework.stereotype.Repository;

@Repository
public interface ProjectRepository extends JpaRepository<Project, Long>{

Optional<Project> findBySdlcSystemIdAndId(long sdlcSystemId, long projectId);
Optional<Project> findByExternalIdAndSdlcSystemId(String externalId, long sdlcSystemId);

/**
* Finds another project with the same key. Doesn't flush pending changes first, so it can
* check a modified project before that change is written.
*/
@QueryHints(@QueryHint(name = HibernateHints.HINT_FLUSH_MODE, value = "COMMIT"))
boolean existsByExternalIdAndSdlcSystemIdAndIdNot(String externalId, long sdlcSystemId, long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,17 @@ public interface ProjectService {
Project createProject(Project project);
Optional<Project> getProject(String externalId, long sdlcSystemId);
Optional<Project> getProjectStream(long id);
Optional<Project> updateProject(Project updatedProject);

/**
* Applies {@code update} to the stored project and saves it in one transaction. The change is
* rolled back if the update throws, the result violates the entity's constraints
* ({@link jakarta.validation.ConstraintViolationException}) or another project in the same
* SDLC system already has the external ID ({@link com.company.resourceapi.exceptions.ConflictException}).
*/
Project updateProject(long id, ProjectUpdate update) throws NotFoundException;

@FunctionalInterface
interface ProjectUpdate {
void applyTo(Project project) throws NotFoundException;
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.company.resourceapi.services;

import com.company.resourceapi.entities.SdlcSystem;
import java.util.Optional;
import com.company.resourceapi.exceptions.NotFoundException;

public interface SdlcSystemService {
Optional<SdlcSystem> getSdlcSystem(long id);
SdlcSystem getSdlcSystem(long id) throws NotFoundException;
}
Loading
Loading