Exponer el endpoint REST

En el paso anterior conectaste CreateTaskUseCase con TaskService. Hasta aqui el caso de uso solo es accesible desde codigo. Ahora vas a exponerlo por HTTP para que clientes externos puedan crear tareas.

El scaffold ya te dejo un CreateTaskController esqueleto en task.adapter.in.web y un CreateTaskRequest vacio en el mismo paquete. En este paso vas a:

  1. Llenar el Request DTO con los campos que el cliente envia.

  2. Completar el mapeo Request → Command.

  3. Probar el endpoint con curl y verificar las validaciones.

1. Completar el Request DTO

El Request necesita los mismos campos que tu Command — title y type. Anotalos con jakarta.validation.constraints.*:

@Data
public class CreateTaskRequest {

    @NotBlank
    private String title;

    @NotNull
    private IssueType type;
}

@NotBlank rechaza titulos vacios o solo whitespace. @NotNull exige el tipo. Si una validacion falla, Spring devuelve 400 Bad Request con la lista de violaciones — no necesitas codigo de manejo de errores en el controller.

Por que un Request separado del Command si hoy tienen los mismos campos. El Request es el DTO HTTP (lo que llega del cliente). El Command es el DTO de aplicacion (lo que entiende el caso de uso). Hoy son iguales — pero el dia que el JSON cambie sin romper la operacion (recibir title_es y title_en y elegir uno), el desacople ya esta hecho. Si los colapsaras en una sola clase, ese cambio te obligaria a tocar el dominio.

2. Mapear Request → Command

El controller no llama al servicio con el Request — lo traduce a CreateTaskCommand. El buildCommand que te dejo el scaffold devuelve un Command vacio. Corrigelo:

private CreateTaskCommand buildCommand(CreateTaskRequest request) {
    return new CreateTaskCommand(request.getTitle(), request.getType());
}

Para que el constructor con dos parametros funcione, agrega @AllArgsConstructor al CreateTaskCommand que armaste en el paso anterior:

@Data
@AllArgsConstructor
public class CreateTaskCommand {
    private String title;
    private IssueType type;
}

El controller, una vez completo, queda asi:

@Tag(name = ApiConstants.API_TAG)
@RestController
@RequestMapping("/v1/tasks")
@RequiredArgsConstructor
class CreateTaskController {

    private final CreateTaskUseCase createTaskUseCase;

    @Operation(summary = "Create a new task")
    @PostMapping
    ResponseEntity<String> create(@RequestBody @Valid CreateTaskRequest request) {
        String id = createTaskUseCase.create(buildCommand(request));
        return ResponseEntity.created(URI.create("/v1/tasks/" + id)).body(id);
    }

    private CreateTaskCommand buildCommand(CreateTaskRequest request) {
        return new CreateTaskCommand(request.getTitle(), request.getType());
    }
}

Observaciones:

  • La clase es package-private — los consumidores externos hablan via HTTP, no via Java.

  • @RequestMapping("/v1/tasks") cubre la version. WebConfig antepone el apiBasePath que diste al generar el proyecto (/tasks), asi que la URL real es /tasks/v1/tasks.

  • El controller depende de la interfaz CreateTaskUseCase, no de TaskService.

3. Probar el endpoint

Arranca la app:

mvn spring-boot:run

Llama al endpoint. X-Tenant-Id es obligatorio — sin el, el TenantContextFilter no resuelve contexto y los datasources fallan:

curl -i -X POST http://localhost:8080/tasks/v1/tasks \
    -H 'Content-Type: application/json' \
    -H 'X-Tenant-Id: 674b414f-27b5-461b-a8da-933326669018' \
    -d '{"title":"Comprar pan","type":"CHORE"}'

Respuesta esperada:

HTTP/1.1 201 Created
Location: /v1/tasks/K3jBLm9xQp1
Content-Type: text/plain

K3jBLm9xQp1

El id que devuelve es el AlphaID que Task.create(…​) asigno antes del save(). El header Location apunta al recurso recien creado — el GetTaskController lo sirve, ya scaffoldeado con la misma estructura del Create.

Ahora prueba una validacion — un titulo vacio:

curl -i -X POST http://localhost:8080/tasks/v1/tasks \
    -H 'Content-Type: application/json' \
    -H 'X-Tenant-Id: 674b414f-27b5-461b-a8da-933326669018' \
    -d '{"title":"","type":"CHORE"}'
{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Request validation failed.",
  "violations": [
    { "field": "title", "message": "must not be blank" }
  ]
}

Sin un solo try/catch en el controller — GlobalExceptionHandler lo cubre globalmente.

Que queda fuera de este paso

  • GET de detalleGetTaskController ya esta scaffoldeado con la misma forma (@PathVariable String id, un TaskResponse con factory of(…​), y ResponseEntity.ok(…​)). Ver GET de detalle en el how-to.

  • Listado paginadoPageable + PageResponse. No hay scaffold para esto. Ver GET paginado en el how-to.

  • Errores de negocio — hoy solo validaste casos de input invalido. Cuando el caso de uso lance una ApplicationException (recurso no encontrado, invariante violada), el handler global la mapea a HTTP. Ver reference: errores.

Siguiente paso

Tu API ya acepta requests y devuelve 201. En el proximo capitulo vas a agregar persistencia con JPA.