refactor: replace Spring Boot with Jooby framework

- Remove Spring Boot dependencies and annotations.
- Implement Jooby MVC controllers and Guice dependency injection.
- Migrate persistence layer to Ebean ORM.
- Configure Flyway migrations and ApiErrorController.
- Update application configuration to HOCON format.
This commit is contained in:
2026-03-02 16:38:11 +01:00
parent 83070ccbda
commit 5790722fea
68 changed files with 1082 additions and 718 deletions

View File

@@ -1,12 +1,23 @@
<project>
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.pablotj</groupId>
<artifactId>portfolio-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>infrastructure</artifactId>
<properties>
<jooby.version>3.6.1</jooby.version>
<ebean.version>15.8.0</ebean.version>
<lombok.version>1.18.36</lombok.version>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencies>
<dependency>
<groupId>com.pablotj</groupId>
@@ -19,36 +30,71 @@
<version>${project.version}</version>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<groupId>io.jooby</groupId>
<artifactId>jooby</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<groupId>io.jooby</groupId>
<artifactId>jooby-ebean</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-hibernate-validator</artifactId>
<version>3.6.1</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-jackson</artifactId>
<version>${jooby.version}</version>
</dependency>
<!-- MapStruct -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
<version>3.0.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.ebean</groupId>
<artifactId>ebean-maven-plugin</artifactId>
<version>${ebean.version}</version>
<executions>
<execution>
<id>main</id>
<phase>process-classes</phase>
<goals>
<goal>enhance</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,34 +1,30 @@
package com.pablotj.portfolio.infrastructure.config;
import io.jooby.Extension;
import io.jooby.Jooby;
import io.jooby.handler.Cors;
import io.jooby.handler.CorsHandler;
import jakarta.annotation.Nonnull;
import java.util.Arrays;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
public class CorsConfig {
@Value("${app.cors.allowed-origins}")
private String allowedOriginsString;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
public class CorsConfig implements Extension {
@Override
public void install(@Nonnull Jooby app) {
// Leemos la configuración
String allowedOriginsString = app.getConfig().getString("app.cors.allowed-origins");
List<String> allowedOrigins = Arrays.asList(allowedOriginsString.split(","));
config.setAllowedOriginPatterns(allowedOrigins);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
// Definimos la configuración de CORS
Cors cors = new Cors()
.setOrigin(allowedOrigins)
.setMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"))
.setHeaders(Arrays.asList("Content-Type", "Authorization", "X-Requested-With"))
.setUseCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
// En Jooby 3, los Handlers de este tipo se registran con .use()
// Esto lo añade al pipeline de ejecución antes de llegar a las rutas
app.use(new CorsHandler(cors));
}
}
}

View File

@@ -1,31 +1,22 @@
package com.pablotj.portfolio.infrastructure.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
import io.jooby.Extension;
import io.jooby.Jooby;
import jakarta.annotation.Nonnull;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
public class SecurityConfig implements Extension {
private final CorsConfigurationSource corsConfigurationSource;
@Override
public void install(@Nonnull Jooby app) {
// En Jooby, al no instalar módulos de seguridad (como pac4j o jwt),
// todas las rutas son accesibles según se definan en los controladores.
public SecurityConfig(CorsConfigurationSource corsConfigurationSource) {
this.corsConfigurationSource = corsConfigurationSource;
// El CSRF en Jooby es un handler que solo se activa si lo instalas,
// así que al no hacer nada aquí, ya está "disabled" por defecto.
// El CORS ya se gestiona en la clase CorsConfig que instalamos en App.java.
// Si en el futuro necesitas seguridad (JWT, Basic, etc.),
// aquí es donde añadiríamos los "Before" handlers de Jooby.
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build();
}
}
}

View File

@@ -5,36 +5,49 @@ import com.pablotj.portfolio.domain.certification.CertificationId;
import com.pablotj.portfolio.domain.certification.port.CertificationRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.certification.entity.CertificationJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.certification.mapper.CertificationJpaMapper;
import com.pablotj.portfolio.infrastructure.persistence.certification.repo.SpringDataCertificationRepository;
import io.ebean.DB;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@Repository
@Singleton // JSR-330 en lugar de @Repository
public class CertificationRepositoryAdapter implements CertificationRepositoryPort {
private final SpringDataCertificationRepository repo;
private final CertificationJpaMapper mapper;
public CertificationRepositoryAdapter(SpringDataCertificationRepository repo, CertificationJpaMapper mapper) {
this.repo = repo;
@Inject
public CertificationRepositoryAdapter(CertificationJpaMapper mapper) {
this.mapper = mapper;
}
@Override
public Certification save(Certification p) {
CertificationJpaEntity entity = mapper.toEntity(p);
CertificationJpaEntity saved = repo.save(entity);
return mapper.toDomain(saved);
// Ebean detecta automáticamente si es insert o update por el ID
DB.save(entity);
return mapper.toDomain(entity);
}
@Override
public Optional<Certification> findById(CertificationId id) {
return repo.findByProfileIdAndId(id.profileId(), id.certificationId()).map(mapper::toDomain);
CertificationJpaEntity entity = DB.find(CertificationJpaEntity.class)
.where()
.eq("profile.id", id.profileId())
.eq("id", id.certificationId())
.findOne();
return Optional.ofNullable(entity).map(mapper::toDomain);
}
@Override
public List<Certification> findAll(Long profileId) {
return repo.findAllByProfileId(profileId).stream().map(mapper::toDomain).toList();
return DB.find(CertificationJpaEntity.class)
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(mapper::toDomain)
.toList();
}
}

View File

@@ -1,6 +1,7 @@
package com.pablotj.portfolio.infrastructure.persistence.certification.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileJpaEntity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -17,7 +18,7 @@ import lombok.Setter;
@Table(name = "CERTIFICATION")
@Getter
@Setter
public class CertificationJpaEntity {
public class CertificationJpaEntity extends Model { // Extendemos de Model para soporte Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -1,12 +1,11 @@
package com.pablotj.portfolio.infrastructure.persistence.certification.mapper;
import com.pablotj.portfolio.domain.certification.Certification;
import com.pablotj.portfolio.domain.certification.CertificationId;
import com.pablotj.portfolio.infrastructure.persistence.certification.entity.CertificationJpaEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface CertificationJpaMapper {
@Mapping(target = "id", ignore = true)

View File

@@ -3,9 +3,8 @@ package com.pablotj.portfolio.infrastructure.persistence.certification.repo;
import com.pablotj.portfolio.infrastructure.persistence.certification.entity.CertificationJpaEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpringDataCertificationRepository extends JpaRepository<CertificationJpaEntity, Long> {
public interface SpringDataCertificationRepository {
Optional<CertificationJpaEntity> findByProfileIdAndId(Long profileId, Long id);

View File

@@ -5,36 +5,48 @@ import com.pablotj.portfolio.domain.education.EducationId;
import com.pablotj.portfolio.domain.education.port.EducationRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.education.entity.EducationJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.education.mapper.EducationJpaMapper;
import com.pablotj.portfolio.infrastructure.persistence.education.repo.SpringDataEducationRepository;
import io.ebean.DB;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@Repository
@Singleton
public class EducationRepositoryAdapter implements EducationRepositoryPort {
private final SpringDataEducationRepository repo;
private final EducationJpaMapper mapper;
public EducationRepositoryAdapter(SpringDataEducationRepository repo, EducationJpaMapper mapper) {
this.repo = repo;
@Inject
public EducationRepositoryAdapter(EducationJpaMapper mapper) {
this.mapper = mapper;
}
@Override
public Education save(Education p) {
EducationJpaEntity entity = mapper.toEntity(p);
EducationJpaEntity saved = repo.save(entity);
return mapper.toDomain(saved);
DB.save(entity);
return mapper.toDomain(entity);
}
@Override
public Optional<Education> findById(EducationId id) {
return repo.findByProfileIdAndId(id.profileId(), id.educationId()).map(mapper::toDomain);
EducationJpaEntity entity = DB.find(EducationJpaEntity.class)
.where()
.eq("profile.id", id.profileId())
.eq("id", id.educationId())
.findOne();
return Optional.ofNullable(entity).map(mapper::toDomain);
}
@Override
public List<Education> findAll(Long profileId) {
return repo.findAllByProfileId(profileId).stream().map(mapper::toDomain).toList();
return DB.find(EducationJpaEntity.class)
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(mapper::toDomain)
.toList();
}
}

View File

@@ -1,6 +1,7 @@
package com.pablotj.portfolio.infrastructure.persistence.education.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileJpaEntity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -17,7 +18,7 @@ import lombok.Setter;
@Table(name = "EDUCATION")
@Getter
@Setter
public class EducationJpaEntity {
public class EducationJpaEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -41,4 +42,4 @@ public class EducationJpaEntity {
@Column(columnDefinition = "text")
private String description;
}
}

View File

@@ -1,12 +1,11 @@
package com.pablotj.portfolio.infrastructure.persistence.education.mapper;
import com.pablotj.portfolio.domain.education.Education;
import com.pablotj.portfolio.domain.education.EducationId;
import com.pablotj.portfolio.infrastructure.persistence.education.entity.EducationJpaEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface EducationJpaMapper {
@Mapping(target = "id", ignore = true)

View File

@@ -1,12 +1,10 @@
package com.pablotj.portfolio.infrastructure.persistence.education.repo;
import com.pablotj.portfolio.infrastructure.persistence.certification.entity.CertificationJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.education.entity.EducationJpaEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpringDataEducationRepository extends JpaRepository<EducationJpaEntity, Long> {
public interface SpringDataEducationRepository {
Optional<EducationJpaEntity> findByProfileIdAndId(Long profileId, Long id);
List<EducationJpaEntity> findAllByProfileId(Long profileId);

View File

@@ -5,36 +5,52 @@ import com.pablotj.portfolio.domain.experience.ExperienceId;
import com.pablotj.portfolio.domain.experience.port.ExperienceRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.experience.entity.ExperienceJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.experience.mapper.ExperienceJpaMapper;
import com.pablotj.portfolio.infrastructure.persistence.experience.repo.SpringDataExperienceRepository;
import io.ebean.DB;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@Repository
@Singleton
public class ExperienceRepositoryAdapter implements ExperienceRepositoryPort {
private final SpringDataExperienceRepository repo;
private final ExperienceJpaMapper mapper;
public ExperienceRepositoryAdapter(SpringDataExperienceRepository repo, ExperienceJpaMapper mapper) {
this.repo = repo;
@Inject
public ExperienceRepositoryAdapter(ExperienceJpaMapper mapper) {
this.mapper = mapper;
}
@Override
public Experience save(Experience p) {
ExperienceJpaEntity entity = mapper.toEntity(p);
ExperienceJpaEntity saved = repo.save(entity);
return mapper.toDomain(saved);
DB.save(entity);
return mapper.toDomain(entity);
}
@Override
public Optional<Experience> findById(ExperienceId id) {
return repo.findByProfileIdAndId(id.profileId(), id.experienceId()).map(mapper::toDomain);
ExperienceJpaEntity entity = DB.find(ExperienceJpaEntity.class)
.fetch("technologies") // Carga ansiosa para evitar N+1
.fetch("achievements")
.where()
.eq("profile.id", id.profileId())
.eq("id", id.experienceId())
.findOne();
return Optional.ofNullable(entity).map(mapper::toDomain);
}
@Override
public List<Experience> findAll(Long profileId) {
return repo.findAllByProfileId(profileId).stream().map(mapper::toDomain).toList();
return DB.find(ExperienceJpaEntity.class)
.fetch("technologies")
.fetch("achievements")
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(mapper::toDomain)
.toList();
}
}

View File

@@ -1,5 +1,6 @@
package com.pablotj.portfolio.infrastructure.persistence.experience.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@@ -20,7 +21,7 @@ import lombok.Setter;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class ExperienceAchievementJpaEntity {
public class ExperienceAchievementJpaEntity extends Model { // Adaptado para Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -1,6 +1,7 @@
package com.pablotj.portfolio.infrastructure.persistence.experience.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileJpaEntity;
import io.ebean.Model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -27,7 +28,7 @@ import lombok.Setter;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class ExperienceJpaEntity {
public class ExperienceJpaEntity extends Model { // Extensión para Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -52,6 +53,7 @@ public class ExperienceJpaEntity {
@Column(columnDefinition = "text")
private String description;
// Ebean soporta perfectamente CascadeType.ALL y orphanRemoval
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "EXPERIENCE_ID")
private List<ExperienceSkillJpaEntity> technologies;

View File

@@ -1,5 +1,6 @@
package com.pablotj.portfolio.infrastructure.persistence.experience.entity;
import io.ebean.Model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
@@ -19,7 +20,7 @@ import lombok.Setter;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class ExperienceSkillJpaEntity {
public class ExperienceSkillJpaEntity extends Model { // Extensión necesaria para el motor Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -9,7 +9,7 @@ import com.pablotj.portfolio.infrastructure.persistence.experience.entity.Experi
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface ExperienceJpaMapper {
@Mapping(target = "id", ignore = true)

View File

@@ -1,12 +1,10 @@
package com.pablotj.portfolio.infrastructure.persistence.experience.repo;
import com.pablotj.portfolio.infrastructure.persistence.education.entity.EducationJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.experience.entity.ExperienceJpaEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpringDataExperienceRepository extends JpaRepository<ExperienceJpaEntity, Long> {
public interface SpringDataExperienceRepository {
Optional<ExperienceJpaEntity> findByProfileIdAndId(Long profileId, Long id);
List<ExperienceJpaEntity> findAllByProfileId(Long profileId);

View File

@@ -4,48 +4,63 @@ import com.pablotj.portfolio.domain.profile.Profile;
import com.pablotj.portfolio.domain.profile.ProfileId;
import com.pablotj.portfolio.domain.profile.port.ProfileRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileSocialLinkJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.profile.mapper.ProfileJpaMapper;
import com.pablotj.portfolio.infrastructure.persistence.profile.repo.SpringDataProfileRepository;
import io.ebean.DB;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@Repository
@Singleton
public class ProfileRepositoryAdapter implements ProfileRepositoryPort {
private final SpringDataProfileRepository repo;
private final ProfileJpaMapper mapper;
public ProfileRepositoryAdapter(SpringDataProfileRepository repo, ProfileJpaMapper mapper) {
this.repo = repo;
@Inject
public ProfileRepositoryAdapter(ProfileJpaMapper mapper) {
this.mapper = mapper;
}
@Override
public Profile save(Profile p) {
ProfileJpaEntity entity = mapper.toEntity(p);
if (entity.getSocial() != null) {
for (ProfileSocialLinkJpaEntity socialLinkJpaEntity : entity.getSocial()) {
socialLinkJpaEntity.setProfile(entity);
}
}
ProfileJpaEntity saved = repo.save(entity);
return mapper.toDomain(saved);
// Ebean gestiona las relaciones Cascade de forma muy limpia.
// Si el mapper ya asocia los ProfileSocialLinkJpaEntity, DB.save lo procesa todo.
DB.save(entity);
return mapper.toDomain(entity);
}
@Override
public Optional<Profile> findBySlug(ProfileId id) {
return repo.findBySlug(id.slug()).map(mapper::toDomain);
ProfileJpaEntity entity = DB.find(ProfileJpaEntity.class)
.fetch("social") // Traemos los links sociales de una vez
.where()
.eq("slug", id.slug())
.findOne();
return Optional.ofNullable(entity).map(mapper::toDomain);
}
@Override
public Optional<Profile> findById(ProfileId id) {
return repo.findById(id.id()).map(mapper::toDomain);
ProfileJpaEntity entity = DB.find(ProfileJpaEntity.class)
.fetch("social")
.where()
.eq("id", id.id())
.findOne();
return Optional.ofNullable(entity).map(mapper::toDomain);
}
@Override
public List<Profile> findAll() {
return repo.findAll().stream().map(mapper::toDomain).toList();
return DB.find(ProfileJpaEntity.class)
.fetch("social")
.findList()
.stream()
.map(mapper::toDomain)
.toList();
}
}

View File

@@ -1,5 +1,6 @@
package com.pablotj.portfolio.infrastructure.persistence.profile.entity;
import io.ebean.Model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -17,7 +18,7 @@ import lombok.Setter;
@Table(name = "profile")
@Getter
@Setter
public class ProfileJpaEntity {
public class ProfileJpaEntity extends Model { // Adaptación para Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -47,7 +48,7 @@ public class ProfileJpaEntity {
@Column
private String avatar;
@Column
@Column(columnDefinition = "text") // Recomendado para bio si es larga
private String bio;
@OneToMany(mappedBy = "profile", cascade = CascadeType.ALL, orphanRemoval = true)

View File

@@ -1,5 +1,6 @@
package com.pablotj.portfolio.infrastructure.persistence.profile.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -16,7 +17,8 @@ import lombok.Setter;
@Table(name = "profile_social_link")
@Getter
@Setter
public class ProfileSocialLinkJpaEntity {
public class ProfileSocialLinkJpaEntity extends Model { // Adaptación para Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

View File

@@ -7,7 +7,7 @@ import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileSo
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface ProfileJpaMapper {
@Mapping(target = "id", ignore = true)

View File

@@ -2,9 +2,8 @@ package com.pablotj.portfolio.infrastructure.persistence.profile.repo;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileJpaEntity;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpringDataProfileRepository extends JpaRepository<ProfileJpaEntity, Long> {
public interface SpringDataProfileRepository {
Optional<ProfileJpaEntity> findBySlug(String slug);
Optional<ProfileJpaEntity> findBySlug(String slug);
}

View File

@@ -5,37 +5,54 @@ import com.pablotj.portfolio.domain.project.ProjectId;
import com.pablotj.portfolio.domain.project.port.ProjectRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.project.entity.ProjectJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.project.mapper.ProjectJpaMapper;
import com.pablotj.portfolio.infrastructure.persistence.project.repo.SpringDataProjectRepository;
import org.springframework.stereotype.Repository;
import io.ebean.DB;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
@Repository
@Singleton
public class ProjectRepositoryAdapter implements ProjectRepositoryPort {
private final SpringDataProjectRepository repo;
private final ProjectJpaMapper mapper;
public ProjectRepositoryAdapter(SpringDataProjectRepository repo, ProjectJpaMapper mapper) {
this.repo = repo;
@Inject
public ProjectRepositoryAdapter(ProjectJpaMapper mapper) {
this.mapper = mapper;
}
@Override
public Project save(Project p) {
ProjectJpaEntity entity = mapper.toEntity(p);
ProjectJpaEntity saved = repo.save(entity);
return mapper.toDomain(saved);
// Ebean maneja el grafo de persistencia (tecnologías y características)
// basándose en las anotaciones Cascade que pusimos en la entidad.
DB.save(entity);
return mapper.toDomain(entity);
}
@Override
public Optional<Project> findById(ProjectId id) {
return repo.findByProfileIdAndId(id.profileId(), id.projectId()).map(mapper::toDomain);
ProjectJpaEntity entity = DB.find(ProjectJpaEntity.class)
.fetch("technologies")
.fetch("features")
.where()
.eq("profile.id", id.profileId())
.eq("id", id.projectId())
.findOne();
return Optional.ofNullable(entity).map(mapper::toDomain);
}
@Override
public List<Project> findAll(Long profileId) {
return repo.findAllByProfileId(profileId).stream().map(mapper::toDomain).toList();
return DB.find(ProjectJpaEntity.class)
.fetch("technologies")
.fetch("features")
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(mapper::toDomain)
.toList();
}
}

View File

@@ -1,5 +1,6 @@
package com.pablotj.portfolio.infrastructure.persistence.project.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@@ -13,7 +14,7 @@ import lombok.Setter;
@Table(name = "PROJECT_FEATURE")
@Getter
@Setter
public class ProjectFeatureJpaEntity {
public class ProjectFeatureJpaEntity extends Model { // Adaptación para Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -1,6 +1,7 @@
package com.pablotj.portfolio.infrastructure.persistence.project.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileJpaEntity;
import io.ebean.Model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -18,8 +19,9 @@ import lombok.Setter;
@Entity
@Table(name = "PROJECT")
@Getter @Setter
public class ProjectJpaEntity {
@Getter
@Setter
public class ProjectJpaEntity extends Model { // Adaptación para Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -32,7 +34,7 @@ public class ProjectJpaEntity {
@Column
private String title;
@Column
@Column(columnDefinition = "text")
private String description;
@Column

View File

@@ -1,5 +1,6 @@
package com.pablotj.portfolio.infrastructure.persistence.project.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@@ -13,7 +14,7 @@ import lombok.Setter;
@Table(name = "PROJECT_FEATURE_TECHNOLOGY")
@Getter
@Setter
public class ProjectTechnologyJpaEntity {
public class ProjectTechnologyJpaEntity extends Model { // Adaptación para el motor Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -9,7 +9,7 @@ import com.pablotj.portfolio.infrastructure.persistence.project.entity.ProjectTe
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface ProjectJpaMapper {
@Mapping(target = "id", ignore = true)

View File

@@ -1,12 +1,10 @@
package com.pablotj.portfolio.infrastructure.persistence.project.repo;
import com.pablotj.portfolio.infrastructure.persistence.experience.entity.ExperienceJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.project.entity.ProjectJpaEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpringDataProjectRepository extends JpaRepository<ProjectJpaEntity, Long> {
public interface SpringDataProjectRepository {
Optional<ProjectJpaEntity> findByProfileIdAndId(Long profileId, Long id);
List<ProjectJpaEntity> findAllByProfileId(Long profileId);

View File

@@ -5,36 +5,51 @@ import com.pablotj.portfolio.domain.skill.SkillGroupId;
import com.pablotj.portfolio.domain.skill.port.SkillRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.skill.entity.SkillGroupJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.skill.mapper.SkillJpaMapper;
import com.pablotj.portfolio.infrastructure.persistence.skill.repo.SpringDataSkillRepository;
import io.ebean.DB;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.springframework.stereotype.Repository;
@Repository
@Singleton
public class SkillRepositoryAdapter implements SkillRepositoryPort {
private final SpringDataSkillRepository repo;
private final SkillJpaMapper mapper;
public SkillRepositoryAdapter(SpringDataSkillRepository repo, SkillJpaMapper mapper) {
this.repo = repo;
@Inject
public SkillRepositoryAdapter(SkillJpaMapper mapper) {
this.mapper = mapper;
}
@Override
public SkillGroup save(SkillGroup p) {
SkillGroupJpaEntity entity = mapper.toEntity(p);
SkillGroupJpaEntity saved = repo.save(entity);
return mapper.toDomain(saved);
// Ebean persiste el SkillGroup y sus SkillJpaEntity asociadas por cascada
DB.save(entity);
return mapper.toDomain(entity);
}
@Override
public Optional<SkillGroup> findById(SkillGroupId id) {
return repo.findByProfileIdAndId(id.profileId(), id.skillGroupId()).map(mapper::toDomain);
SkillGroupJpaEntity entity = DB.find(SkillGroupJpaEntity.class)
.fetch("skills") // Carga inmediata de la lista de habilidades
.where()
.eq("profile.id", id.profileId())
.eq("id", id.skillGroupId())
.findOne();
return Optional.ofNullable(entity).map(mapper::toDomain);
}
@Override
public List<SkillGroup> findAll(Long profileId) {
return repo.findAllByProfileId(profileId).stream().map(mapper::toDomain).toList();
return DB.find(SkillGroupJpaEntity.class)
.fetch("skills")
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(mapper::toDomain)
.toList();
}
}

View File

@@ -1,6 +1,7 @@
package com.pablotj.portfolio.infrastructure.persistence.skill.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileJpaEntity;
import io.ebean.Model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
@@ -20,7 +21,7 @@ import lombok.Setter;
@Table(name = "SKILL_GROUP")
@Getter
@Setter
public class SkillGroupJpaEntity {
public class SkillGroupJpaEntity extends Model { // Adaptación para Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -1,5 +1,6 @@
package com.pablotj.portfolio.infrastructure.persistence.skill.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
@@ -13,7 +14,7 @@ import lombok.Setter;
@Table(name = "SKILL")
@Getter
@Setter
public class SkillJpaEntity {
public class SkillJpaEntity extends Model { // Adaptación para Ebean
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)

View File

@@ -7,7 +7,7 @@ import com.pablotj.portfolio.infrastructure.persistence.skill.entity.SkillJpaEnt
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface SkillJpaMapper {
@Mapping(target = "id", ignore = true)

View File

@@ -1,12 +1,10 @@
package com.pablotj.portfolio.infrastructure.persistence.skill.repo;
import com.pablotj.portfolio.infrastructure.persistence.project.entity.ProjectJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.skill.entity.SkillGroupJpaEntity;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpringDataSkillRepository extends JpaRepository<SkillGroupJpaEntity, Long> {
public interface SpringDataSkillRepository {
Optional<SkillGroupJpaEntity> findByProfileIdAndId(Long profileId, Long id);
List<SkillGroupJpaEntity> findAllByProfileId(Long profileId);

View File

@@ -1,30 +1,28 @@
package com.pablotj.portfolio.infrastructure.rest.api;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.WebRequest;
import io.jooby.ErrorHandler;
import java.util.LinkedHashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Controller
public class ApiErrorController implements ErrorController {
public class ApiErrorController {
private final ErrorAttributes errorAttributes;
private static final Logger log = LoggerFactory.getLogger(ApiErrorController.class);
public ApiErrorController(ErrorAttributes errorAttributes) {
this.errorAttributes = errorAttributes;
}
public static ErrorHandler getHandler() {
return (ctx, cause, statusCode) -> {
log.error("Error en la API: {}", cause.getMessage(), cause);
@RequestMapping("/error")
public ResponseEntity<Map<String, Object>> handleError(WebRequest webRequest) {
Map<String, Object> attributes = errorAttributes.getErrorAttributes(webRequest,
ErrorAttributeOptions.defaults());
HttpStatus status = HttpStatus.valueOf((int) attributes.getOrDefault("status", 500));
return new ResponseEntity<>(attributes, status);
Map<String, Object> errorAttributes = new LinkedHashMap<>();
errorAttributes.put("timestamp", System.currentTimeMillis());
errorAttributes.put("status", statusCode.value());
errorAttributes.put("error", statusCode.reason());
errorAttributes.put("message", cause.getMessage());
errorAttributes.put("path", ctx.getRequestPath());
ctx.setResponseCode(statusCode)
.render(errorAttributes);
};
}
}

View File

@@ -1,39 +1,41 @@
package com.pablotj.portfolio.infrastructure.rest.api;
import io.jooby.annotation.GET;
import io.jooby.annotation.Path;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping
@Path("/")
public class ApiRootController {
@Value("${info.app.version}")
private String appVersion;
private final String appVersion;
@GetMapping
public ResponseEntity<Map<String, Object>> root() {
Map<String, Object> response = Map.of(
@Inject
public ApiRootController(@Named("info.app.version") String appVersion) {
// Jooby/Guice inyecta directamente el valor de la propiedad
// Esto es el equivalente a @Value de Spring
this.appVersion = appVersion;
}
@GET
public Map<String, Object> root() {
return Map.of(
"api", "Portfolio API",
"version", appVersion,
"doc", "/v3/api-docs",
"swagger", "/swagger-ui",
"endpoints", List.of(
Map.of("path", "/v1/homes", "description", "Manage projects"),
Map.of("path", "/v1/certifications", "description", "Manage projects"),
Map.of("path", "/v1/homes", "description", "Manage home details"),
Map.of("path", "/v1/certifications", "description", "Manage certifications"),
Map.of("path", "/v1/projects", "description", "Manage projects"),
Map.of("path", "/v1/contacts", "description", "Manage projects"),
Map.of("path", "/v1/educations", "description", "Manage projects"),
Map.of("path", "/v1/experiences", "description", "Manage projects"),
Map.of("path", "/v1/projects", "description", "Manage projects"),
Map.of("path", "/v1/technologies", "description", "ProfileSocialLink entries")
Map.of("path", "/v1/contacts", "description", "Manage contact info"),
Map.of("path", "/v1/educations", "description", "Manage education"),
Map.of("path", "/v1/experiences", "description", "Manage experience"),
Map.of("path", "/v1/skills", "description", "Manage skills"),
Map.of("path", "/v1/technologies", "description", "Manage technologies")
)
);
return ResponseEntity.ok(response);
}
}
}

View File

@@ -5,54 +5,53 @@ import com.pablotj.portfolio.application.certification.GetCertificationUseCase;
import com.pablotj.portfolio.infrastructure.rest.certification.dto.CertificationDto;
import com.pablotj.portfolio.infrastructure.rest.certification.dto.CreateCertificationRequest;
import com.pablotj.portfolio.infrastructure.rest.certification.mapper.CertificationRestMapper;
import io.jooby.annotation.GET;
import io.jooby.annotation.POST;
import io.jooby.annotation.Path;
import io.jooby.annotation.PathParam;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.mapstruct.factory.Mappers;
@RestController
@RequestMapping("/v1/profiles/{profileId}/certifications")
@Path("/v1/profiles/{profileId}/certifications")
public class CertificationController {
private static final CertificationRestMapper MAPPER = Mappers.getMapper(CertificationRestMapper.class);
private final CreateCertificationUseCase createUC;
private final GetCertificationUseCase getUC;
private final CertificationRestMapper mapper;
public CertificationController(CreateCertificationUseCase createUC, GetCertificationUseCase getUC, CertificationRestMapper mapper) {
@Inject
public CertificationController(CreateCertificationUseCase createUC, GetCertificationUseCase getUC) {
this.createUC = createUC;
this.getUC = getUC;
this.mapper = mapper;
}
@GetMapping
public List<CertificationDto> all(@PathVariable Long profileId) {
return getUC.all(profileId).stream().map(mapper::toDto).toList();
@GET
public List<CertificationDto> all(@PathParam Long profileId) {
return getUC.all(profileId).stream()
.map(MAPPER::toDto)
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<CertificationDto> byId(@PathVariable Long profileId, @PathVariable Long id) {
@GET("/{id}")
public CertificationDto byId(@PathParam Long profileId, @PathParam Long id) {
// En Jooby, si devuelves un Optional vacío, automáticamente lanza un 404
return getUC.byId(profileId, id)
.map(mapper::toDto)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
.map(MAPPER::toDto)
.orElseThrow(() -> new io.jooby.exception.NotFoundException("Certification not found"));
}
@PostMapping
public ResponseEntity<CertificationDto> create(@PathVariable Long profileId, @Valid @RequestBody CreateCertificationRequest request) {
@POST
public CertificationDto create(@PathParam Long profileId, @Valid CreateCertificationRequest request) {
var cmd = new CreateCertificationUseCase.Command(
request.name(),
request.issuer(),
request.date(),
request.credentialId()
);
var created = createUC.handle(profileId, cmd);
var body = mapper.toDto(created);
return ResponseEntity.created(URI.create("/api/certifications/" + body.id())).body(body);
return MAPPER.toDto(created);
}
}

View File

@@ -5,7 +5,7 @@ import com.pablotj.portfolio.infrastructure.rest.certification.dto.Certification
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface CertificationRestMapper {
@Mapping(target = "id", source = "id.certificationId")

View File

@@ -5,46 +5,45 @@ import com.pablotj.portfolio.application.education.GetEducationUseCase;
import com.pablotj.portfolio.infrastructure.rest.education.dto.CreateEducationRequest;
import com.pablotj.portfolio.infrastructure.rest.education.dto.EducationDto;
import com.pablotj.portfolio.infrastructure.rest.education.mapper.EducationRestMapper;
import io.jooby.annotation.GET;
import io.jooby.annotation.POST;
import io.jooby.annotation.Path;
import io.jooby.annotation.PathParam;
import io.jooby.exception.NotFoundException;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.mapstruct.factory.Mappers;
@RestController
@RequestMapping("/v1/profiles/{profileId}/education")
@Path("/v1/profiles/{profileId}/education")
public class EducationController {
private static final EducationRestMapper MAPPER = Mappers.getMapper(EducationRestMapper.class);
private final CreateEducationUseCase createUC;
private final GetEducationUseCase getUC;
private final EducationRestMapper mapper;
public EducationController(CreateEducationUseCase createUC, GetEducationUseCase getUC, EducationRestMapper mapper) {
@Inject
public EducationController(CreateEducationUseCase createUC, GetEducationUseCase getUC) {
this.createUC = createUC;
this.getUC = getUC;
this.mapper = mapper;
}
@GetMapping
public List<EducationDto> all(@PathVariable Long profileId) {
return getUC.all(profileId).stream().map(mapper::toDto).toList();
@GET
public List<EducationDto> all(@PathParam Long profileId) {
return getUC.all(profileId).stream()
.map(MAPPER::toDto)
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<EducationDto> byId(@PathVariable Long profileId, @PathVariable Long id) {
@GET("/{id}")
public EducationDto byId(@PathParam Long profileId, @PathParam Long id) {
return getUC.byId(profileId, id)
.map(mapper::toDto)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
.map(MAPPER::toDto)
.orElseThrow(() -> new NotFoundException("Education record not found"));
}
@PostMapping
public ResponseEntity<EducationDto> create(@PathVariable Long profileId, @Valid @RequestBody CreateEducationRequest request) {
@POST
public EducationDto create(@PathParam Long profileId, @Valid CreateEducationRequest request) {
var cmd = new CreateEducationUseCase.Command(
request.institution(),
request.degree(),
@@ -52,8 +51,8 @@ public class EducationController {
request.grade(),
request.description()
);
var created = createUC.handle(profileId, cmd);
var body = mapper.toDto(created);
return ResponseEntity.created(URI.create("/api/educations/" + body.id())).body(body);
return MAPPER.toDto(created);
}
}

View File

@@ -5,7 +5,7 @@ import com.pablotj.portfolio.infrastructure.rest.education.dto.EducationDto;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface EducationRestMapper {
@Mapping(target = "id", source = "id.educationId")

View File

@@ -5,46 +5,45 @@ import com.pablotj.portfolio.application.experience.GetExperienceUseCase;
import com.pablotj.portfolio.infrastructure.rest.experience.dto.CreateExperienceRequest;
import com.pablotj.portfolio.infrastructure.rest.experience.dto.ExperienceDto;
import com.pablotj.portfolio.infrastructure.rest.experience.mapper.ExperienceRestMapper;
import io.jooby.annotation.GET;
import io.jooby.annotation.POST;
import io.jooby.annotation.Path;
import io.jooby.annotation.PathParam;
import io.jooby.exception.NotFoundException;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.mapstruct.factory.Mappers;
@RestController
@RequestMapping("/v1/profiles/{profileId}/experience")
@Path("/v1/profiles/{profileId}/experience")
public class ExperienceController {
private static final ExperienceRestMapper MAPPER = Mappers.getMapper(ExperienceRestMapper.class);
private final CreateExperienceUseCase createUC;
private final GetExperienceUseCase getUC;
private final ExperienceRestMapper mapper;
public ExperienceController(CreateExperienceUseCase createUC, GetExperienceUseCase getUC, ExperienceRestMapper mapper) {
@Inject
public ExperienceController(CreateExperienceUseCase createUC, GetExperienceUseCase getUC) {
this.createUC = createUC;
this.getUC = getUC;
this.mapper = mapper;
}
@GetMapping
public List<ExperienceDto> all(@PathVariable Long profileId) {
return getUC.all(profileId).stream().map(mapper::toDto).toList();
@GET
public List<ExperienceDto> all(@PathParam Long profileId) {
return getUC.all(profileId).stream()
.map(MAPPER::toDto)
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<ExperienceDto> byId(@PathVariable Long profileId, @PathVariable Long id) {
@GET("/{id}")
public ExperienceDto byId(@PathParam Long profileId, @PathParam Long id) {
return getUC.byId(profileId, id)
.map(mapper::toDto)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
.map(MAPPER::toDto)
.orElseThrow(() -> new NotFoundException("Experience not found"));
}
@PostMapping
public ResponseEntity<ExperienceDto> create(@PathVariable Long profileId, @Valid @RequestBody CreateExperienceRequest request) {
@POST
public ExperienceDto create(@PathParam Long profileId, @Valid CreateExperienceRequest request) {
var cmd = new CreateExperienceUseCase.Command(
request.company(),
request.position(),
@@ -54,8 +53,8 @@ public class ExperienceController {
request.technologies(),
request.achievements()
);
var created = createUC.handle(profileId, cmd);
var body = mapper.toDto(created);
return ResponseEntity.created(URI.create("/api/experiences/" + body.id())).body(body);
return MAPPER.toDto(created);
}
}

View File

@@ -11,4 +11,5 @@ public record CreateExperienceRequest(
String description,
List<String> technologies,
List<String> achievements
) {}
) {
}

View File

@@ -12,4 +12,5 @@ public record ExperienceDto(
String description,
List<String> technologies,
List<String> achievements
) {}
) {
}

View File

@@ -8,7 +8,7 @@ import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface ExperienceRestMapper {
@Mapping(target = "id", source = "id.experienceId")

View File

@@ -5,47 +5,45 @@ import com.pablotj.portfolio.application.profile.GetProfileUseCase;
import com.pablotj.portfolio.infrastructure.rest.profile.dto.ProfileCreateRequest;
import com.pablotj.portfolio.infrastructure.rest.profile.dto.ProfileDto;
import com.pablotj.portfolio.infrastructure.rest.profile.mapper.ProfileRestMapper;
import io.jooby.annotation.GET;
import io.jooby.annotation.POST;
import io.jooby.annotation.Path;
import io.jooby.annotation.PathParam;
import io.jooby.exception.NotFoundException;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.mapstruct.factory.Mappers;
@RestController
@RequestMapping("/v1/profiles")
@Path("/v1/profiles")
public class ProfileController {
private static final ProfileRestMapper MAPPER = Mappers.getMapper(ProfileRestMapper.class);
private final CreateProfileUseCase createUC;
private final GetProfileUseCase getUC;
private final ProfileRestMapper mapper;
public ProfileController(CreateProfileUseCase createUC, GetProfileUseCase getUC, ProfileRestMapper mapper) {
@Inject
public ProfileController(CreateProfileUseCase createUC, GetProfileUseCase getUC) {
this.createUC = createUC;
this.getUC = getUC;
this.mapper = mapper;
}
@GetMapping
@GET
public List<ProfileDto> all() {
return getUC.all().stream().map(mapper::toDto).toList();
return getUC.all().stream()
.map(MAPPER::toDto)
.toList();
}
@GetMapping("/{slug}")
public ResponseEntity<ProfileDto> byId(@PathVariable String slug) {
@GET("/{slug}")
public ProfileDto byId(@PathParam String slug) {
return getUC.bySlug(slug)
.map(mapper::toDto)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
.map(MAPPER::toDto)
.orElseThrow(() -> new NotFoundException("Profile not found"));
}
@PostMapping
public ResponseEntity<ProfileDto> create(@Valid @RequestBody ProfileCreateRequest request) {
@POST
public ProfileDto create(@Valid ProfileCreateRequest request) {
var cmd = new CreateProfileUseCase.Command(
request.slug(),
request.name(),
@@ -56,10 +54,12 @@ public class ProfileController {
request.location(),
request.avatar(),
request.bio(),
request.social().stream().map(l -> new CreateProfileUseCase.Link(l.platform(), l.url())).toList()
request.social().stream()
.map(l -> new CreateProfileUseCase.Link(l.platform(), l.url()))
.toList()
);
var created = createUC.handle(cmd);
var body = mapper.toDto(created);
return ResponseEntity.created(URI.create("/api/homes/" + body.id())).body(body);
return MAPPER.toDto(created);
}
}

View File

@@ -8,7 +8,7 @@ import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface ProfileRestMapper {
@Mapping(target = "id", source = "id.id")

View File

@@ -5,46 +5,45 @@ import com.pablotj.portfolio.application.project.GetProjectUseCase;
import com.pablotj.portfolio.infrastructure.rest.project.dto.CreateProjectRequest;
import com.pablotj.portfolio.infrastructure.rest.project.dto.ProjectDto;
import com.pablotj.portfolio.infrastructure.rest.project.mapper.ProjectRestMapper;
import io.jooby.annotation.GET;
import io.jooby.annotation.POST;
import io.jooby.annotation.Path;
import io.jooby.annotation.PathParam;
import io.jooby.exception.NotFoundException;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.mapstruct.factory.Mappers;
@RestController
@RequestMapping("/v1/profiles/{profileId}/projects")
@Path("/v1/profiles/{profileId}/projects")
public class ProjectController {
private static final ProjectRestMapper MAPPER = Mappers.getMapper(ProjectRestMapper.class);
private final CreateProjectUseCase createUC;
private final GetProjectUseCase getUC;
private final ProjectRestMapper mapper;
public ProjectController(CreateProjectUseCase createUC, GetProjectUseCase getUC, ProjectRestMapper mapper) {
@Inject
public ProjectController(CreateProjectUseCase createUC, GetProjectUseCase getUC) {
this.createUC = createUC;
this.getUC = getUC;
this.mapper = mapper;
}
@GetMapping
public List<ProjectDto> all(@PathVariable Long profileId) {
return getUC.all(profileId).stream().map(mapper::toDto).toList();
@GET
public List<ProjectDto> all(@PathParam Long profileId) {
return getUC.all(profileId).stream()
.map(MAPPER::toDto)
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<ProjectDto> byId(@PathVariable Long profileId, @PathVariable Long id) {
@GET("/{id}")
public ProjectDto byId(@PathParam Long profileId, @PathParam Long id) {
return getUC.byId(profileId, id)
.map(mapper::toDto)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
.map(MAPPER::toDto)
.orElseThrow(() -> new NotFoundException("Project not found"));
}
@PostMapping
public ResponseEntity<ProjectDto> create(@PathVariable Long profileId, @Valid @RequestBody CreateProjectRequest request) {
@POST
public ProjectDto create(@PathParam Long profileId, @Valid CreateProjectRequest request) {
var cmd = new CreateProjectUseCase.Command(
request.title(),
request.description(),
@@ -54,8 +53,8 @@ public class ProjectController {
request.demo(),
request.repository()
);
var created = createUC.handle(profileId, cmd);
var body = mapper.toDto(created);
return ResponseEntity.created(URI.create("/api/projects/" + body.id())).body(body);
return MAPPER.toDto(created);
}
}

View File

@@ -10,4 +10,5 @@ public record CreateProjectRequest(
List<String> features,
String demo,
String repository
) {}
) {
}

View File

@@ -8,7 +8,7 @@ import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface ProjectRestMapper {
@Mapping(target = "id", source = "id.projectId")

View File

@@ -5,53 +5,54 @@ import com.pablotj.portfolio.application.skill.GetSkillUseCase;
import com.pablotj.portfolio.infrastructure.rest.skill.dto.CreateSkillGroupRequest;
import com.pablotj.portfolio.infrastructure.rest.skill.dto.SkillGroupDto;
import com.pablotj.portfolio.infrastructure.rest.skill.mapper.SkillRestMapper;
import io.jooby.annotation.GET;
import io.jooby.annotation.POST;
import io.jooby.annotation.Path;
import io.jooby.annotation.PathParam;
import io.jooby.exception.NotFoundException;
import jakarta.inject.Inject;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.mapstruct.factory.Mappers;
@RestController
@RequestMapping("/v1/profiles/{profileId}/skills")
@Path("/v1/profiles/{profileId}/skills")
public class SkillController {
private static final SkillRestMapper MAPPER = Mappers.getMapper(SkillRestMapper.class);
private final CreateSkillUseCase createUC;
private final GetSkillUseCase getUC;
private final SkillRestMapper mapper;
public SkillController(CreateSkillUseCase createUC, GetSkillUseCase getUC, SkillRestMapper mapper) {
@Inject
public SkillController(CreateSkillUseCase createUC, GetSkillUseCase getUC) {
this.createUC = createUC;
this.getUC = getUC;
this.mapper = mapper;
}
@GetMapping
public List<SkillGroupDto> all(@PathVariable Long profileId) {
return getUC.all(profileId).stream().map(mapper::toDto).toList();
@GET
public List<SkillGroupDto> all(@PathParam Long profileId) {
return getUC.all(profileId).stream()
.map(MAPPER::toDto)
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<SkillGroupDto> byId(@PathVariable Long profileId, @PathVariable Long id) {
@GET("/{id}")
public SkillGroupDto byId(@PathParam Long profileId, @PathParam Long id) {
return getUC.byId(profileId, id)
.map(mapper::toDto)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
.map(MAPPER::toDto)
.orElseThrow(() -> new NotFoundException("Skill group not found"));
}
@PostMapping
public ResponseEntity<SkillGroupDto> create(@PathVariable Long profileId, @Valid @RequestBody CreateSkillGroupRequest request) {
@POST
public SkillGroupDto create(@PathParam Long profileId, @Valid CreateSkillGroupRequest request) {
var cmd = new CreateSkillUseCase.CommandGroup(
request.name(),
request.icon(),
request.skills().stream().map(s -> new CreateSkillUseCase.CommandSkill(s.name(), s.level(), s.years())).toList()
request.skills().stream()
.map(s -> new CreateSkillUseCase.CommandSkill(s.name(), s.level(), s.years()))
.toList()
);
var created = createUC.handle(profileId, cmd);
var body = mapper.toDto(created);
return ResponseEntity.created(URI.create("/api/skills/" + body.id())).body(body);
return MAPPER.toDto(created);
}
}

View File

@@ -7,7 +7,7 @@ import com.pablotj.portfolio.infrastructure.rest.skill.dto.SkillGroupDto;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
@Mapper
public interface SkillRestMapper {
@Mapping(target = "id", source = "id.skillGroupId")

View File

@@ -0,0 +1,168 @@
create table personal
(
id bigint not null
primary key,
avatar varchar(255),
bio varchar(255),
email varchar(255),
location varchar(255),
name varchar(255),
phone varchar(255),
subtitle varchar(255),
title varchar(255)
);
create table personal_social_link
(
id bigint not null
primary key,
platform varchar(255),
url varchar(255),
personal_id bigint not null
constraint fkfh1pbfvvg3palcr1yip6jffik
references personal
);
create table profile
(
id bigint not null
primary key,
avatar varchar(255),
bio varchar(4000),
email varchar(255),
location varchar(255),
name varchar(255),
phone varchar(255),
slug varchar(255),
subtitle varchar(255),
title varchar(255)
);
create table certification
(
id bigint not null
primary key,
credential_id varchar(255),
date varchar(255),
issuer varchar(255),
name varchar(255),
profile_id bigint not null
constraint fko6ve4ysx15lc2vcjt84sal1yc
references profile
);
create table education
(
id bigint not null
primary key,
degree varchar(255),
description varchar,
grade varchar(255),
institution varchar(255),
period varchar(255),
profile_id bigint not null
constraint fkelocxwwcyf5acj85hgke1c0fl
references profile
);
create table experience
(
id bigint not null
primary key,
company varchar(255),
description varchar,
location varchar(255),
period varchar(255),
position varchar(255),
profile_id bigint not null
constraint fkhlkosu9yvtv1ptp01x4tfh9ut
references profile
);
create table experience_achievement
(
id bigint not null
primary key,
description varchar,
experience_id bigint
constraint fk94xrk6stofkung8skwplo29nd
references experience
);
create table experience_skill
(
id bigint not null
primary key,
name varchar(255),
experience_id bigint
constraint fkpr3jdfjjlaubuayoafpwyx2al
references experience
);
create table profile_social_link
(
id bigint not null
primary key,
platform varchar(255),
url varchar(255),
profile_id bigint not null
constraint fkqfxt1g0xm211i7qjnlcuqfes9
references profile
);
create table project
(
id bigint not null
primary key,
demo varchar(255),
description varchar(255),
image varchar(255),
repository varchar(255),
title varchar(255),
profile_id bigint not null
constraint fk2i9umkiuu36osx3afamsxq39h
references profile
);
create table project_feature
(
id bigint not null
primary key,
name varchar(255),
project_id bigint
constraint fkdifppyvrfito5in15ox4db0up
references project
);
create table project_feature_technology
(
id bigint not null
primary key,
name varchar(255),
project_id bigint
constraint fk15krsajtovetpg5vsaqj3icwf
references project
);
create table skill_group
(
id bigint not null
primary key,
icon varchar(255),
name varchar(255),
profile_id bigint not null
constraint fko26hcvag49ctl3ciddsqm6mn1
references profile
);
create table skill
(
id bigint not null
primary key,
level integer,
name varchar(255),
years integer,
skill_id bigint
constraint fki819li5g5cp5qbsyenhr3kmef
references skill_group
);

View File

@@ -1,140 +0,0 @@
create table certification
(
id bigint generated by default as identity,
credential_id varchar(255),
date varchar(255),
issuer varchar(255),
name varchar(255),
primary key (id)
);
create table education
(
id bigint generated by default as identity,
degree varchar(255),
description text,
grade varchar(255),
institution varchar(255),
period varchar(255),
primary key (id)
);
create table experience
(
id bigint generated by default as identity,
company varchar(255),
description text,
location varchar(255),
period varchar(255),
position varchar(255),
primary key (id)
);
create table experience_achievement
(
id bigint generated by default as identity,
description text,
experience_id bigint,
primary key (id)
);
create table experience_skill
(
id bigint generated by default as identity,
name varchar(255),
experience_id bigint,
primary key (id)
);
create table personal
(
id bigint generated by default as identity,
avatar varchar(255),
bio varchar(255),
email varchar(255),
location varchar(255),
name varchar(255),
phone varchar(255),
subtitle varchar(255),
title varchar(255),
primary key (id)
);
create table personal_social_link
(
id bigint generated by default as identity,
platform varchar(255),
url varchar(255),
personal_id bigint not null,
primary key (id)
);
create table project
(
id bigint generated by default as identity,
demo varchar(255),
description varchar(255),
image varchar(255),
repository varchar(255),
title varchar(255),
primary key (id)
);
create table project_feature
(
id bigint generated by default as identity,
name varchar(255),
project_id bigint,
primary key (id)
);
create table project_feature_technology
(
id bigint generated by default as identity,
name varchar(255),
project_id bigint,
primary key (id)
);
create table skill
(
id bigint generated by default as identity,
level integer,
name varchar(255),
years integer,
skill_id bigint,
primary key (id)
);
create table skill_group
(
id bigint generated by default as identity,
icon varchar(255),
name varchar(255),
primary key (id)
);
alter table if exists experience_achievement
add constraint FK94xrk6stofkung8skwplo29nd
foreign key (experience_id)
references experience;
alter table if exists experience_skill
add constraint FKpr3jdfjjlaubuayoafpwyx2al
foreign key (experience_id)
references experience;
alter table if exists project_feature
add constraint FKdifppyvrfito5in15ox4db0up
foreign key (project_id)
references project;
alter table if exists project_feature_technology
add constraint FK15krsajtovetpg5vsaqj3icwf
foreign key (project_id)
references project;
alter table if exists skill
add constraint FKi819li5g5cp5qbsyenhr3kmef
foreign key (skill_id)
references skill_group;