In a recruitment interview I got this small projet to do at home: rest API written in Java using Spring Boot. As I didn't hear back from the company I wanted to have feedback from review. I am a rest api/spring boot beginner.
Requirements: Design a rest API managing horse races and their particpants:
- A race hase a name, date and unique number for that date
- A race has at least 3 participants
- A particpant has a name and an unique id
- The participant in a race have consecutive ids starting with 1
The API must expose methods for:
- Create races
- Create their participants
- Persist both in a sql db
- Add Kafka producer/consumer layer so that the races could be read from systems outside ( As I've never used Kafka, I didn't do that part)
Here is my code, following Controller -> Service -> Repository pattern:
- Domain
/***RACE***/
public interface IRace {
int MIN_COURSE_SIZE = 3;
String getName();
LocalDate getDate();
int getDayNumber();
Set<IRunner> getRunners();
void addRunner(String name);
}
@Entity
@Table(name = "RACE")
@RequiredArgsConstructor
@AllArgsConstructor
public class Race implements IRace {
private AtomicInteger runnersCount = new AtomicInteger(1);
@EmbeddedId
@Getter
RaceId id;
@OneToMany(mappedBy = "race", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@Getter
private Set<IRunner> runners = new HashSet<>();
public Race(RaceId id) {
this.id = id;
}
@Override
public String getName() {
return id.getName();
}
@Override
public LocalDate getDate() {
return id.getDate();
}
@Override
public int getDayNumber() {
return id.getDayNumber();
}
@Override
public Set<IRunner> getRunners() {
return runners;
}
@Override
public void addRunner(String name) {
runners.add(new Runner(name, runnersCount.getAndIncrement(), this));
}
@Override
public int hashCode() {
return Objects.hash(id.getName(), id.getDate(), id.getDayNumber());
}
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Race))
return false;
Race other = (Race) o;
return this.id.equals(other.id);
}
@Override
public String toString() {
return "[Race: name=" + getName() + " date=" + getDate() + " dayNumber=" + getDayNumber() + " runners=" + runnersCount;
}
}
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
public class RaceId implements Serializable {
private static final long serialVersionUID = -9092062626878526790L;
@Column(name = "race_name", nullable = false)
@Getter
private String name;
@Column(name = "race_date", nullable = false)
@Getter
private LocalDate date;
@Column(name="race_day_number",nullable = false)
@Positive
@Getter
private int dayNumber;
@Override
public int hashCode() {
return Objects.hash(name, date, dayNumber);
}
@Override
public boolean equals(Object obj) {
if (obj == this)
return true;
if (!(obj instanceof RaceId))
return false;
RaceId other = (RaceId) obj;
return this.name.equals(other.name) && this.date.equals(other.date) && this.dayNumber == other.dayNumber;
}
public String getName() {
return name;
}
public LocalDate getDate() {
return date;
}
public int getDayNumber() {
return dayNumber;
}
}
public class RaceValidator implements ConstraintValidator<ValidRace, IRace> {
@Override
public void initialize(ValidRace constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(IRace race, ConstraintValidatorContext constraintValidatorContext) {
if (!isValid(race)) {
throw new IllegalArgumentException("Invalid race");
}
return true;
}
/**
* Validates following rules:
* 1. Course runners must be at least n (n = ICourse.MinCourseSize)
* 2. Runner numbers must be n first integers starting from 1
* 3. Runners must all be part of the same race
*
* @param race
* @return true if race satisfies the conditions, false otherwise
*/
private boolean isValid(IRace race) {
int n = race.getRunners().size();
if (n <= IRace.MIN_COURSE_SIZE) {
return false;
}
int validSum = (n * (n + 1)) / 2;
int sum = race.getRunners().stream().mapToInt(r -> r.getNumber()).sum();
if (sum != validSum) {
return false;
}
if (!race.getRunners().stream().allMatch(r -> r.getCourse().equals(race))) {
return false;
}
return true;
}
}
@Constraint(validatedBy = RaceValidator.class)
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidRace {
String message() default "Invalid course";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
/*** RUNNER ***/
public interface IRunner {
String getName();
int getNumber();
void setNumber(int number);
IRace getCourse();
}
@Entity
@Table(name = "RUNNER")
@NoArgsConstructor
public class Runner implements IRunner {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Getter
private Long id;
@Column(name = "NAME", nullable = false)
@Getter
private String name;
@Column(name = "NUMBER")
@Getter
@Setter
private int number;
@ManyToOne
@JoinColumns({
@JoinColumn(
name = "race_name",
referencedColumnName = "race_name"),
@JoinColumn(
name = "race_date",
referencedColumnName = "race_date"),
@JoinColumn(
name = "race_day_number",
referencedColumnName = "race_day_number")
})
@Getter
private IRace race;
public Runner(String name, int number, IRace course) {
this.name = name;
this.number = number;
this.race = course;
}
@Override
public int hashCode() {
return Objects.hash(name, number, race);
}
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Runner))
return false;
Runner other = (Runner) o;
return this.name.equals(other.name) && this.number == other.number && this.race.equals(other.race);
}
}
- Repository
public interface RaceRepository extends CrudRepository<IRace, RaceId> {
Iterable<IRace> findByDate(LocalDate date);
Optional<IRace> findByDateAndDayNumber(LocalDate date, int dayNumber);
}
- Service
public interface IRaceService {
IRace save(IRace course);
Iterable<IRace> findAll();
Iterable<IRace> findByDate(LocalDate date);
Optional<IRace> findByDateAndDayNumber(LocalDate date, int dayNumber);
}
@Slf4j
@Service
public class RaceService implements IRaceService {
@Autowired
private RaceRepository raceRepository;
@Override
public IRace save(IRace race) {
return raceRepository.save(race);
}
@Override
public Iterable<IRace> findAll() {
return raceRepository.findAll();
}
@Override
public Iterable<IRace> findByDate(org.joda.time.LocalDate date) {
return raceRepository.findByDate(date);
}
@Override
public Optional<IRace> findByDateAndDayNumber(LocalDate date, int dayNumber) {
return raceRepository.findByDateAndDayNumber(date, dayNumber);
}
}
public class InvalidRaceException extends Exception {
public InvalidRaceException(String s) {
super(s);
}
}
- Controller
/**
* Generic wrapper for http response
*/
@Data
@SuperBuilder
@JsonInclude(Include.NON_NULL)
public class GenericResponse {
protected LocalDateTime timeStamp;
protected HttpStatus httpStatus;
protected String reason;
protected String message;
protected String developerMessage;
protected Map data;
}
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.OK;
@Validated
@Controller
@RequestMapping("/race")
public class RaceController {
@Autowired
private IRaceService raceService;
@GetMapping("/races")
public ResponseEntity<GenericResponse> findAll() {
GenericResponse genericResponse = GenericResponse.builder().build();
genericResponse.timeStamp = LocalDateTime.now();
genericResponse.data = Map.of("races", raceService.findAll());
genericResponse.message = "races found";
genericResponse.httpStatus = OK;
return ResponseEntity.ok(genericResponse);
}
@GetMapping(value = "/race/{date}")
public ResponseEntity<GenericResponse> findByDate(@PathVariable("date") LocalDate date) {
GenericResponse genericResponse = GenericResponse.builder().build();
genericResponse.timeStamp = LocalDateTime.now();
genericResponse.data = Map.of("races", raceService.findByDate(date));
genericResponse.message = "races found for date: " + date;
genericResponse.httpStatus = OK;
return ResponseEntity.ok(genericResponse);
}
@GetMapping(value = "/race", params = {"date", "dayNumber"})
public ResponseEntity<GenericResponse> findByDateAndDayNumber(@PathVariable("date") LocalDate date, @PathVariable("dayNumber") int dayNumber) {
GenericResponse genericResponse = GenericResponse.builder().build();
genericResponse.timeStamp = LocalDateTime.now();
genericResponse.data = Map.of("races", raceService.findByDateAndDayNumber(date, dayNumber));
genericResponse.message = "races found";
genericResponse.httpStatus = OK;
return ResponseEntity.ok(genericResponse);
}
@PostMapping("/race")
public ResponseEntity<GenericResponse> addRace(@RequestBody @ValidRace IRace race) {
GenericResponse genericResponse = GenericResponse.builder().build();
genericResponse.timeStamp = LocalDateTime.now();
genericResponse.data = Map.of("races", raceService.save(race));
genericResponse.message = "race created";
genericResponse.httpStatus = CREATED;
return ResponseEntity.ok(genericResponse);
}
@PostMapping("/race/{runnerName}")
public ResponseEntity<GenericResponse> addRunner(@RequestBody @ValidRace IRace race, @PathVariable String runnerName) {
race.addRunner(runnerName);
GenericResponse genericResponse = GenericResponse.builder().build();
genericResponse.timeStamp = LocalDateTime.now();
genericResponse.data = Map.of("races", raceService.save(race));
genericResponse.message = "race created";
genericResponse.httpStatus = CREATED;
return ResponseEntity.ok(genericResponse);
}
}
Any critics are welcomed. Thank you very much