Implement REST API controllers, web adapters, and API documentation

- Create UserController, WorkoutController, and SessionController to expose application use cases via REST endpoints
- Add request and response DTOs alongside web mappers for the User, Workout, and Session domains
- Introduce GlobalExceptionHandler to handle domain-specific exceptions and return HTTP 400 Bad Request responses
- Add Springdoc OpenAPI and Scalar dependencies to build.gradle for API documentation
- Configure OpenAPI and Scalar settings in the base and dev application properties
This commit is contained in:
João P.A Silveira 2026-06-09 21:01:00 -03:00
parent 5c1b7bcdd2
commit b2314bcf36
24 changed files with 506 additions and 0 deletions

View file

@ -24,6 +24,12 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.apache.groovy:groovy' implementation 'org.apache.groovy:groovy'
// Source: https://mvnrepository.com/artifact/com.scalar.maven/scalar-webmvc
implementation 'com.scalar.maven:scalar-webmvc:0.6.37'
// Source: https://mvnrepository.com/artifact/org.springdoc/springdoc-openapi-starter-webmvc-scalar
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-scalar:3.0.3'
developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-devtools'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose' developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'

View file

@ -0,0 +1,40 @@
package br.dev.jsilveira.coresync.common.adapter.in.web
import br.dev.jsilveira.coresync.session.domain.exception.UserWorkoutSessionException
import br.dev.jsilveira.coresync.user.domain.exception.UserException
import br.dev.jsilveira.coresync.workout.domain.exception.WorkoutDayException
import br.dev.jsilveira.coresync.workout.domain.exception.WorkoutExerciseException
import br.dev.jsilveira.coresync.workout.domain.exception.WorkoutPlanException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(UserException)
ResponseEntity<Map> handleUserException(UserException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body([error: ex.message])
}
@ExceptionHandler(WorkoutPlanException)
ResponseEntity<Map> handleWorkoutPlanException(WorkoutPlanException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body([error: ex.message])
}
@ExceptionHandler(WorkoutDayException)
ResponseEntity<Map> handleWorkoutDayException(WorkoutDayException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body([error: ex.message])
}
@ExceptionHandler(WorkoutExerciseException)
ResponseEntity<Map> handleWorkoutExerciseException(WorkoutExerciseException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body([error: ex.message])
}
@ExceptionHandler(UserWorkoutSessionException)
ResponseEntity<Map> handleSessionException(UserWorkoutSessionException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body([error: ex.message])
}
}

View file

@ -1 +1,24 @@
package br.dev.jsilveira.coresync.session.adapter.in.web package br.dev.jsilveira.coresync.session.adapter.in.web
import br.dev.jsilveira.coresync.session.adapter.in.web.dto.UserWorkoutSessionResponse
import br.dev.jsilveira.coresync.session.adapter.in.web.mapper.SessionWebMapper
import br.dev.jsilveira.coresync.session.application.port.in.UpdateUserWorkoutSessionCompletedAtUseCase
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/sessions")
class SessionController {
private final UpdateUserWorkoutSessionCompletedAtUseCase updateUserWorkoutSessionCompletedAtUseCase
private final SessionWebMapper webMapper = new SessionWebMapper()
SessionController(UpdateUserWorkoutSessionCompletedAtUseCase updateUserWorkoutSessionCompletedAtUseCase) {
this.updateUserWorkoutSessionCompletedAtUseCase = updateUserWorkoutSessionCompletedAtUseCase
}
@PatchMapping("/{id}/complete")
UserWorkoutSessionResponse complete(@PathVariable UUID id) {
def session = updateUserWorkoutSessionCompletedAtUseCase.execute(id)
return webMapper.toResponse(session)
}
}

View file

@ -0,0 +1,13 @@
package br.dev.jsilveira.coresync.session.adapter.in.web.dto
import java.time.LocalDateTime
record UserWorkoutSessionResponse(
UUID id,
UUID userId,
UUID workoutDayId,
LocalDateTime startedAt,
LocalDateTime completedAt,
Boolean isCompleted,
Long durationInMinutes
) {}

View file

@ -0,0 +1,18 @@
package br.dev.jsilveira.coresync.session.adapter.in.web.mapper
import br.dev.jsilveira.coresync.session.adapter.in.web.dto.UserWorkoutSessionResponse
import br.dev.jsilveira.coresync.session.domain.model.UserWorkoutSession
class SessionWebMapper {
UserWorkoutSessionResponse toResponse(UserWorkoutSession domain) {
return new UserWorkoutSessionResponse(
domain.id(),
domain.userId(),
domain.workoutDayId(),
domain.startedAt(),
domain.completedAt(),
domain.isCompleted(),
domain.getDurationInMinutes()
)
}
}

View file

@ -1 +1,79 @@
package br.dev.jsilveira.coresync.user.adapter.in.web package br.dev.jsilveira.coresync.user.adapter.in.web
import br.dev.jsilveira.coresync.user.adapter.in.web.dto.CreateUserRequest
import br.dev.jsilveira.coresync.user.adapter.in.web.dto.UserResponse
import br.dev.jsilveira.coresync.user.adapter.in.web.mapper.UserWebMapper
import br.dev.jsilveira.coresync.user.application.port.in.CreateUserUseCase
import br.dev.jsilveira.coresync.user.application.port.in.DisableUserUseCase
import br.dev.jsilveira.coresync.user.application.port.in.EnableUserUseCase
import br.dev.jsilveira.coresync.user.application.port.in.UpdateUserEmailUseCase
import br.dev.jsilveira.coresync.user.application.port.in.UpdateUserImageUseCase
import br.dev.jsilveira.coresync.user.application.port.in.UpdateUserNameUseCase
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/users")
class UserController {
private final CreateUserUseCase createUserUseCase
private final EnableUserUseCase enableUserUseCase
private final DisableUserUseCase disableUserUseCase
private final UpdateUserNameUseCase updateUserNameUseCase
private final UpdateUserEmailUseCase updateUserEmailUseCase
private final UpdateUserImageUseCase updateUserImageUseCase
private final UserWebMapper webMapper = new UserWebMapper()
UserController(
CreateUserUseCase createUserUseCase,
EnableUserUseCase enableUserUseCase,
DisableUserUseCase disableUserUseCase,
UpdateUserNameUseCase updateUserNameUseCase,
UpdateUserEmailUseCase updateUserEmailUseCase,
UpdateUserImageUseCase updateUserImageUseCase
) {
this.createUserUseCase = createUserUseCase
this.enableUserUseCase = enableUserUseCase
this.disableUserUseCase = disableUserUseCase
this.updateUserNameUseCase = updateUserNameUseCase
this.updateUserEmailUseCase = updateUserEmailUseCase
this.updateUserImageUseCase = updateUserImageUseCase
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
UserResponse create(@RequestBody CreateUserRequest request) {
def user = createUserUseCase.execute(request.name(), request.email())
return webMapper.toResponse(user)
}
@PatchMapping("/{id}/activate")
UserResponse activate(@PathVariable UUID id) {
def user = enableUserUseCase.execute(id)
return webMapper.toResponse(user)
}
@PatchMapping("/{id}/deactivate")
UserResponse deactivate(@PathVariable UUID id) {
def user = disableUserUseCase.execute(id)
return webMapper.toResponse(user)
}
@PatchMapping("/{id}/name")
UserResponse updateName(@PathVariable UUID id, @RequestBody Map body) {
def user = updateUserNameUseCase.execute(id, body.name as String)
return webMapper.toResponse(user)
}
@PatchMapping("/{id}/email")
UserResponse updateEmail(@PathVariable UUID id, @RequestBody Map body) {
def user = updateUserEmailUseCase.execute(id, body.email as String)
return webMapper.toResponse(user)
}
@PatchMapping("/{id}/image")
UserResponse updateImage(@PathVariable UUID id, @RequestBody Map body) {
def user = updateUserImageUseCase.execute(id, body.image as String)
return webMapper.toResponse(user)
}
}

View file

@ -0,0 +1,6 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.dto
record CreateUserRequest(
String name,
String email
) {}

View file

@ -0,0 +1,5 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.dto
record DisableUserEmailVerifiedRequest(
UUID id
) {}

View file

@ -0,0 +1,5 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.dto
record DisableUserRequest(
UUID id
) {}

View file

@ -0,0 +1,5 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.dto
record EnableUserEmailVerified(
UUID id
){}

View file

@ -0,0 +1,5 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.dto
record EnableUserRequest(
UUID id
) {}

View file

@ -0,0 +1,6 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.dto
record UpdateUserEmailRequest(
UUID id,
String email
) {}

View file

@ -0,0 +1,6 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.dto
record UpdateUserImageRequest (
UUID id,
String image
){}

View file

@ -0,0 +1,6 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.dto
record UpdateUserNameRequest(
UUID id,
String name
) {}

View file

@ -0,0 +1,14 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.dto
import java.time.LocalDateTime
record UserResponse(
UUID id,
String name,
String email,
Boolean emailVerified,
String image,
LocalDateTime createdAt,
LocalDateTime updatedAt,
Boolean isActive
) {}

View file

@ -0,0 +1,19 @@
package br.dev.jsilveira.coresync.user.adapter.in.web.mapper
import br.dev.jsilveira.coresync.user.adapter.in.web.dto.UserResponse
import br.dev.jsilveira.coresync.user.domain.model.User
class UserWebMapper {
UserResponse toResponse(User domain) {
return new UserResponse(
domain.id(),
domain.name(),
domain.email(),
domain.emailVerified(),
domain.image(),
domain.createdAt(),
domain.updatedAt(),
domain.isActive()
)
}
}

View file

@ -1 +1,101 @@
package br.dev.jsilveira.coresync.workout.adapter.in.web package br.dev.jsilveira.coresync.workout.adapter.in.web
import br.dev.jsilveira.coresync.workout.adapter.in.web.dto.CreateWorkoutPlanRequest
import br.dev.jsilveira.coresync.workout.adapter.in.web.dto.WorkoutDayResponse
import br.dev.jsilveira.coresync.workout.adapter.in.web.dto.WorkoutExerciseResponse
import br.dev.jsilveira.coresync.workout.adapter.in.web.dto.WorkoutPlanResponse
import br.dev.jsilveira.coresync.workout.adapter.in.web.mapper.WorkoutWebMapper
import br.dev.jsilveira.coresync.workout.application.port.in.CreateWorkoutPlanUseCase
import br.dev.jsilveira.coresync.workout.application.port.in.DisableWorkoutDayRestUseCase
import br.dev.jsilveira.coresync.workout.application.port.in.DisableWorkoutPlanUseCase
import br.dev.jsilveira.coresync.workout.application.port.in.EnableWorkoutDayRestUseCase
import br.dev.jsilveira.coresync.workout.application.port.in.EnableWorkoutPlanUseCase
import br.dev.jsilveira.coresync.workout.application.port.in.UpdateWorkoutDayNameUseCase
import br.dev.jsilveira.coresync.workout.application.port.in.UpdateWorkoutExerciseNameUseCase
import br.dev.jsilveira.coresync.workout.application.port.in.UpdateWorkoutPlanNameUseCase
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/workouts")
class WorkoutController {
private final CreateWorkoutPlanUseCase createWorkoutPlanUseCase
private final EnableWorkoutPlanUseCase enableWorkoutPlanUseCase
private final DisableWorkoutPlanUseCase disableWorkoutPlanUseCase
private final UpdateWorkoutPlanNameUseCase updateWorkoutPlanNameUseCase
private final UpdateWorkoutDayNameUseCase updateWorkoutDayNameUseCase
private final EnableWorkoutDayRestUseCase enableWorkoutDayRestUseCase
private final DisableWorkoutDayRestUseCase disableWorkoutDayRestUseCase
private final UpdateWorkoutExerciseNameUseCase updateWorkoutExerciseNameUseCase
private final WorkoutWebMapper webMapper = new WorkoutWebMapper()
WorkoutController(
CreateWorkoutPlanUseCase createWorkoutPlanUseCase,
EnableWorkoutPlanUseCase enableWorkoutPlanUseCase,
DisableWorkoutPlanUseCase disableWorkoutPlanUseCase,
UpdateWorkoutPlanNameUseCase updateWorkoutPlanNameUseCase,
UpdateWorkoutDayNameUseCase updateWorkoutDayNameUseCase,
EnableWorkoutDayRestUseCase enableWorkoutDayRestUseCase,
DisableWorkoutDayRestUseCase disableWorkoutDayRestUseCase,
UpdateWorkoutExerciseNameUseCase updateWorkoutExerciseNameUseCase
) {
this.createWorkoutPlanUseCase = createWorkoutPlanUseCase
this.enableWorkoutPlanUseCase = enableWorkoutPlanUseCase
this.disableWorkoutPlanUseCase = disableWorkoutPlanUseCase
this.updateWorkoutPlanNameUseCase = updateWorkoutPlanNameUseCase
this.updateWorkoutDayNameUseCase = updateWorkoutDayNameUseCase
this.enableWorkoutDayRestUseCase = enableWorkoutDayRestUseCase
this.disableWorkoutDayRestUseCase = disableWorkoutDayRestUseCase
this.updateWorkoutExerciseNameUseCase = updateWorkoutExerciseNameUseCase
}
@PostMapping("/plans")
@ResponseStatus(HttpStatus.CREATED)
WorkoutPlanResponse createPlan(@RequestBody CreateWorkoutPlanRequest request) {
def plan = createWorkoutPlanUseCase.execute(request.name(), UUID.fromString(request.userId()))
return webMapper.toPlanResponse(plan)
}
@PatchMapping("/plans/{id}/activate")
WorkoutPlanResponse activatePlan(@PathVariable UUID id) {
def plan = enableWorkoutPlanUseCase.execute(id)
return webMapper.toPlanResponse(plan)
}
@PatchMapping("/plans/{id}/deactivate")
WorkoutPlanResponse deactivatePlan(@PathVariable UUID id) {
def plan = disableWorkoutPlanUseCase.execute(id)
return webMapper.toPlanResponse(plan)
}
@PatchMapping("/plans/{id}/name")
WorkoutPlanResponse updatePlanName(@PathVariable UUID id, @RequestBody Map body) {
def plan = updateWorkoutPlanNameUseCase.execute(id, body.name as String)
return webMapper.toPlanResponse(plan)
}
@PatchMapping("/days/{id}/name")
WorkoutDayResponse updateDayName(@PathVariable UUID id, @RequestBody Map body) {
def day = updateWorkoutDayNameUseCase.execute(id, body.name as String)
return webMapper.toDayResponse(day)
}
@PatchMapping("/days/{id}/rest")
WorkoutDayResponse enableDayRest(@PathVariable UUID id) {
def day = enableWorkoutDayRestUseCase.execute(id)
return webMapper.toDayResponse(day)
}
@DeleteMapping("/days/{id}/rest")
WorkoutDayResponse disableDayRest(@PathVariable UUID id) {
def day = disableWorkoutDayRestUseCase.execute(id)
return webMapper.toDayResponse(day)
}
@PatchMapping("/exercises/{id}/name")
WorkoutExerciseResponse updateExerciseName(@PathVariable UUID id, @RequestBody Map body) {
def exercise = updateWorkoutExerciseNameUseCase.execute(id, body.name as String)
return webMapper.toExerciseResponse(exercise)
}
}

View file

@ -0,0 +1,6 @@
package br.dev.jsilveira.coresync.workout.adapter.in.web.dto
record CreateWorkoutPlanRequest(
String name,
String userId
) {}

View file

@ -0,0 +1,15 @@
package br.dev.jsilveira.coresync.workout.adapter.in.web.dto
import java.time.LocalDateTime
record WorkoutDayResponse(
UUID id,
String name,
UUID workoutPlanId,
Boolean isRest,
String weekDay,
Integer estimatedDurationInSeconds,
LocalDateTime createdAt,
LocalDateTime updatedAt,
List<WorkoutExerciseResponse> workoutExercises
) {}

View file

@ -0,0 +1,15 @@
package br.dev.jsilveira.coresync.workout.adapter.in.web.dto
import java.time.LocalDateTime
record WorkoutExerciseResponse(
UUID id,
Integer order,
String name,
Integer sets,
Integer reps,
Integer restTimeInSeconds,
UUID workoutDayId,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}

View file

@ -0,0 +1,13 @@
package br.dev.jsilveira.coresync.workout.adapter.in.web.dto
import java.time.LocalDateTime
record WorkoutPlanResponse(
UUID id,
String name,
UUID userId,
Boolean isActive,
LocalDateTime createdAt,
LocalDateTime updateAt,
List<WorkoutDayResponse> workoutDays
) {}

View file

@ -0,0 +1,51 @@
package br.dev.jsilveira.coresync.workout.adapter.in.web.mapper
import br.dev.jsilveira.coresync.workout.adapter.in.web.dto.WorkoutDayResponse
import br.dev.jsilveira.coresync.workout.adapter.in.web.dto.WorkoutExerciseResponse
import br.dev.jsilveira.coresync.workout.adapter.in.web.dto.WorkoutPlanResponse
import br.dev.jsilveira.coresync.workout.domain.model.WorkoutDay
import br.dev.jsilveira.coresync.workout.domain.model.WorkoutExercise
import br.dev.jsilveira.coresync.workout.domain.model.WorkoutPlan
class WorkoutWebMapper {
WorkoutPlanResponse toPlanResponse(WorkoutPlan domain) {
return new WorkoutPlanResponse(
domain.id(),
domain.name(),
domain.userId(),
domain.isActive(),
domain.createdAt(),
domain.updateAt(),
domain.workoutDays().collect { toDayResponse(it) }
)
}
WorkoutDayResponse toDayResponse(WorkoutDay domain) {
return new WorkoutDayResponse(
domain.id(),
domain.name(),
domain.workoutPlanId(),
domain.isRest(),
domain.weekDay() ? domain.weekDay().name() : null,
domain.estimatedDurationInSeconds(),
domain.createdAt(),
domain.updatedAt(),
domain.workoutExercises().collect { toExerciseResponse(it) }
)
}
WorkoutExerciseResponse toExerciseResponse(WorkoutExercise domain) {
return new WorkoutExerciseResponse(
domain.id(),
domain.order(),
domain.name(),
domain.sets(),
domain.reps(),
domain.restTimeInSeconds(),
domain.workoutDayId(),
domain.createdAt(),
domain.updatedAt()
)
}
}

View file

@ -0,0 +1,26 @@
spring.application.name=coresync-dev
# springdoc gera o spec OpenAPI
springdoc.api-docs.path=/v3/api-docs
springdoc.swagger-ui.enabled=false
# Scalar consome o spec do springdoc
scalar.enabled=true
scalar.url=/v3/api-docs
scalar.path=/docs
scalar.pageTitle=CoreSync API
scalar.darkMode=true
scalar.layout= modern
scalar.theme=deep_space
scalar.hide-dark-mode-toggle=true
scalar.hide-models=true
scalar.hide-client-button=false
scalar.show-sidebar=true
scalar.show-developer-tools=localhost
scalar.operation-title-source=summary
scalar.persist-auth=false
scalar.telemetry=true
scalar.expand-all-model-sections=false
scalar.expand-all-responses=false
scalar.order-required-properties-first=true
scalar.dark-mode=true

View file

@ -1 +1,26 @@
spring.application.name=coresync spring.application.name=coresync
# springdoc gera o spec OpenAPI
springdoc.api-docs.path=/v3/api-docs
springdoc.swagger-ui.enabled=false
# Scalar consome o spec do springdoc
scalar.enabled=true
scalar.url=/v3/api-docs
scalar.path=/docs
scalar.pageTitle=CoreSync API
scalar.darkMode=true
scalar.layout= modern
scalar.theme=deep_space
scalar.hide-dark-mode-toggle=true
scalar.hide-models=true
scalar.hide-client-button=false
scalar.show-sidebar=true
scalar.show-developer-tools=localhost
scalar.operation-title-source=summary
scalar.persist-auth=false
scalar.telemetry=true
scalar.expand-all-model-sections=false
scalar.expand-all-responses=false
scalar.order-required-properties-first=true
scalar.dark-mode=true