Compare commits

...

21 Commits

Author SHA1 Message Date
Pablo de la Torre
17e8f83bf7 ci(config): update infrastructure configuration 2026-05-13 19:40:15 +02:00
4eddee6561 ci(config): update infrastructure configuration 2026-05-01 10:34:26 +02:00
707baf8cbc 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.
2026-03-11 11:32:58 +01:00
83070ccbda ci: implements jenkins deploy 2026-03-02 12:40:22 +01:00
4e72e6da77 ci: implements jenkins deploy 2026-02-21 10:44:49 +01:00
5a26b299f2 feat: move CORS configuration to Spring Security 2025-09-20 11:22:48 +02:00
6b3585da5e chore: remove active Spring profile from Dockerfile 2025-09-20 10:48:46 +02:00
e7b6712ad3 fix: correct Docker Compose network configuration 2025-09-20 10:30:19 +02:00
ca41aee64f chore: configure CORS 2025-09-20 10:22:10 +02:00
2959c68bf3 chore: configure Docker to deploy project using environment-based properties 2025-09-20 10:21:52 +02:00
8b2795d518 Merge remote-tracking branch 'origin/main' 2025-09-20 10:08:10 +02:00
8ef605ea2d chore: optimize HikariCP pool and JVM memory for Raspberry Pi 2025-09-20 10:07:59 +02:00
Pablo de la Torre
ee1820960e chore(gitignore): add macOS system files to ignore list 2025-09-10 08:02:47 +02:00
1b55d9ab29 refactor(api): refactor endpoints, services, and domain logic 2025-09-09 19:53:42 +02:00
9f5306545e feat: implement final Experience API 2025-08-27 16:28:23 +02:00
ab40e9a497 feat: implement final Contact API 2025-08-26 17:50:20 +02:00
9ff4b21dd9 feat: implement final Resume API 2025-08-26 17:41:07 +02:00
7f12034174 chore: add minimal implementations for all portfolio API endpoints 2025-08-25 23:04:20 +02:00
df24350bd3 chore: add minimal API documentation at root context 2025-08-25 21:58:09 +02:00
5368425c1d chore: modularized persistence package (entity, repo, adapter) 2025-08-25 21:25:53 +02:00
1033c96d65 chore: add global REST error handling 2025-08-25 21:25:08 +02:00
131 changed files with 3447 additions and 420 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
APP_ALLOWED_ORIGINS='http://127.0.0.1:3000, http://localhost:3000'
DB_NAME=EXAMPLE_DB
DB_USER=EXAMPLE
DB_PASSWORD=SECRET
DB_HOST=127.0.0.1
DB_PORT=5432

8
.gitignore vendored
View File

@@ -1,3 +1,9 @@
.idea .idea
*.toml
*.db
target target
.env
Icon?
.docker
tmp
logs

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# --- Stage 1: Build & Dependencies ---
FROM maven:3.9-eclipse-temurin-21-alpine AS build
WORKDIR /app
# 1. Copiamos los archivos de configuración (Estructura de poms)
COPY pom.xml .
COPY application/pom.xml ./application/
COPY bootstrap/pom.xml ./bootstrap/
COPY domain/pom.xml ./domain/
COPY infrastructure/pom.xml ./infrastructure/
# 2. Descargamos dependencias (Cacheamos esta capa)
# Usamos install de los poms para que los módulos se reconozcan entre sí
RUN mvn dependency:go-offline -B
# 3. Copiamos el código fuente y compilamos
COPY application/src ./application/src
COPY bootstrap/src ./bootstrap/src
COPY domain/src ./domain/src
COPY infrastructure/src ./infrastructure/src
RUN mvn clean package -DskipTests -B
# --- Stage 2: Run (Imagen final ligera) ---
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Seguridad: Usuario no-root
RUN addgroup -S app && adduser -S app -G app
RUN mkdir -p /app/tmp && chown app:app /app/tmp && chmod 777 /app/tmp
RUN mkdir -p /app/logs && chown app:app /app/logs && chmod 777 /app/logs
USER app:app
# Copiamos solo el JAR final (ajustado a tu módulo bootstrap)
COPY --from=build /app/bootstrap/target/bootstrap-*.jar app.jar
# Configuración de Memoria y Rendimiento para Microservicios
# -XX:+UseSerialGC: Menos consumo de RAM para apps < 1GB
# -XX:TieredStopAtLevel=1: Arranque más rápido y menos uso de RAM del compilador JIT
# -XX:MaxRAMPercentage: Se ajusta dinámicamente al límite de Docker
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=50.0 \
-Xms32m \
-Xmx96m \
-XX:+UseSerialGC \
-XX:TieredStopAtLevel=1"
EXPOSE 80
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

31
HELP.md
View File

@@ -1,31 +0,0 @@
# Getting Started
### Reference Documentation
For further reference, please consider the following sections:
* [Official Apache Maven documentation](https://maven.apache.org/guides/index.html)
* [Spring Boot Maven Plugin Reference Guide](https://docs.spring.io/spring-boot/4.0.0-SNAPSHOT/maven-plugin)
* [Create an OCI image](https://docs.spring.io/spring-boot/4.0.0-SNAPSHOT/maven-plugin/build-image.html)
* [Spring Web](https://docs.spring.io/spring-boot/4.0.0-SNAPSHOT/reference/web/servlet.html)
* [Spring Data JPA](https://docs.spring.io/spring-boot/4.0.0-SNAPSHOT/reference/data/sql.html#data.sql.jpa-and-spring-data)
* [Validation](https://docs.spring.io/spring-boot/4.0.0-SNAPSHOT/reference/io/validation.html)
* [Spring Boot DevTools](https://docs.spring.io/spring-boot/4.0.0-SNAPSHOT/reference/using/devtools.html)
* [Spring Boot Actuator](https://docs.spring.io/spring-boot/4.0.0-SNAPSHOT/reference/actuator/index.html)
### Guides
The following guides illustrate how to use some features concretely:
* [Building a RESTful Web Service](https://spring.io/guides/gs/rest-service/)
* [Serving Web Content with Spring MVC](https://spring.io/guides/gs/serving-web-content/)
* [Building REST services with Spring](https://spring.io/guides/tutorials/rest/)
* [Accessing Data with JPA](https://spring.io/guides/gs/accessing-data-jpa/)
* [Validation](https://spring.io/guides/gs/validating-form-input/)
* [Building a RESTful Web Service with Spring Boot Actuator](https://spring.io/guides/gs/actuator-service/)
### Maven Parent overrides
Due to Maven's design, elements are inherited from the parent POM to the project POM.
While most of the inheritance is fine, it also inherits unwanted elements like `<license>` and `<developers>` from the parent.
To prevent this, the project POM contains empty overrides for these elements.
If you manually switch to a different parent and actually want the inheritance, you need to remove those overrides.

77
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,77 @@
pipeline {
agent any
environment {
REGISTRY_URL = "registry.nortapp.com"
USER = "andromeda"
PASS = credentials('docker-registry-password')
}
stages {
stage('Prepare Workspace & Checkout') {
steps {
echo "Cleaning workspace"
deleteDir()
}
}
stage('Checkout') {
steps {
echo "Checking out repo..."
checkout scm
}
}
stage('Load Environment') {
steps {
echo "Loading .env secret from Jenkins..."
withCredentials([file(credentialsId: 'env', variable: 'SECRET_ENV')]) {
sh 'cp $SECRET_ENV .env'
}
}
}
stage('Build and Tag Docker Image') {
steps {
echo "Building Docker image..."
sh '''
make build
make tag
'''
}
}
stage('ush Docker Image') {
steps {
echo "Tagging and pushing Docker image to registry..."
sh '''
echo $PASS | docker login ${REGISTRY_URL} -u ${USER} --password-stdin
make push
'''
}
}
stage('Deploy Docker Container') {
steps {
echo "Stopping old container and running new container..."
sh '''
make stop
make run
'''
}
}
}
post {
success {
echo "✅ Deployment completed successfully!"
}
failure {
echo "❌ Pipeline failed!"
}
always {
deleteDir()
}
}
}

33
Makefile Normal file
View File

@@ -0,0 +1,33 @@
STACK=pablotj-portfolio
APP_NAME=pablotj-portfolio-api
IMAGE_NAME=$(APP_NAME)
REGISTRY_URL=registry.nortapp.com
NAMESPACE=andromeda
TAG?=latest
IMAGE_FULL=$(REGISTRY_URL)/$(NAMESPACE)/$(IMAGE_NAME):$(TAG)
build:
docker build -t $(IMAGE_NAME):$(TAG) .
tag:
docker tag $(IMAGE_NAME):$(TAG) $(IMAGE_FULL)
push:
docker push $(IMAGE_FULL)
run:
docker run -d \
--name $(APP_NAME) \
--label com.docker.compose.service="$(APP_NAME)" \
--label com.docker.compose.project="$(STACK)" \
--network andromeda \
--env-file .env \
$(IMAGE_FULL)
stop:
docker stop $(APP_NAME) || true
docker rm $(APP_NAME) || true
deploy: build tag push stop run

View File

@@ -12,9 +12,17 @@
<artifactId>domain</artifactId> <artifactId>domain</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>jakarta.validation</groupId> <groupId>org.apache.logging.log4j</groupId>
<artifactId>jakarta.validation-api</artifactId> <artifactId>log4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
<scope>provided</scope>
</dependency> </dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -0,0 +1,33 @@
package com.pablotj.portfolio.application.certification;
import com.pablotj.portfolio.domain.certification.Certification;
import com.pablotj.portfolio.domain.certification.CertificationId;
import com.pablotj.portfolio.domain.certification.port.CertificationRepositoryPort;
public class CreateCertificationUseCase {
CertificationRepositoryPort repository;
public CreateCertificationUseCase(CertificationRepositoryPort repository) {
this.repository = repository;
}
public Certification handle(Long profileId, Command cmd) {
var certification = Certification.builder()
.id(new CertificationId(profileId))
.name(cmd.name())
.issuer(cmd.issuer())
.date(cmd.date())
.credentialId(cmd.credentialId())
.build();
return repository.save(certification);
}
public record Command(
String name,
String issuer,
String date,
String credentialId
) {
}
}

View File

@@ -0,0 +1,24 @@
package com.pablotj.portfolio.application.certification;
import com.pablotj.portfolio.domain.certification.Certification;
import com.pablotj.portfolio.domain.certification.CertificationId;
import com.pablotj.portfolio.domain.certification.port.CertificationRepositoryPort;
import java.util.List;
import java.util.Optional;
public class GetCertificationUseCase {
private final CertificationRepositoryPort repository;
public GetCertificationUseCase(CertificationRepositoryPort repository) {
this.repository = repository;
}
public Optional<Certification> byId(Long profileId, Long id) {
return repository.findById(new CertificationId(profileId, id));
}
public List<Certification> all(Long profileId) {
return repository.findAll(profileId);
}
}

View File

@@ -0,0 +1,35 @@
package com.pablotj.portfolio.application.education;
import com.pablotj.portfolio.domain.education.Education;
import com.pablotj.portfolio.domain.education.EducationId;
import com.pablotj.portfolio.domain.education.port.EducationRepositoryPort;
public class CreateEducationUseCase {
private final EducationRepositoryPort repository;
public CreateEducationUseCase(EducationRepositoryPort repository) {
this.repository = repository;
}
public Education handle(Long profileId, Command cmd) {
var education = Education.builder()
.id(new EducationId(profileId))
.institution(cmd.institution())
.degree(cmd.degree())
.period(cmd.period())
.grade(cmd.grade())
.description(cmd.description())
.build();
return repository.save(education);
}
public record Command(
String institution,
String degree,
String period,
String grade,
String description
) {
}
}

View File

@@ -0,0 +1,24 @@
package com.pablotj.portfolio.application.education;
import com.pablotj.portfolio.domain.education.Education;
import com.pablotj.portfolio.domain.education.EducationId;
import com.pablotj.portfolio.domain.education.port.EducationRepositoryPort;
import java.util.List;
import java.util.Optional;
public class GetEducationUseCase {
private final EducationRepositoryPort repository;
public GetEducationUseCase(EducationRepositoryPort repository) {
this.repository = repository;
}
public Optional<Education> byId(Long profileId, Long id) {
return repository.findById(new EducationId(profileId, id));
}
public List<Education> all(Long profileId) {
return repository.findAll(profileId);
}
}

View File

@@ -0,0 +1,45 @@
package com.pablotj.portfolio.application.experience;
import com.pablotj.portfolio.domain.experience.Achievement;
import com.pablotj.portfolio.domain.experience.Experience;
import com.pablotj.portfolio.domain.experience.ExperienceId;
import com.pablotj.portfolio.domain.experience.Technology;
import com.pablotj.portfolio.domain.experience.port.ExperienceRepositoryPort;
import java.util.ArrayList;
import java.util.List;
public class CreateExperienceUseCase {
private final ExperienceRepositoryPort repository;
public CreateExperienceUseCase(ExperienceRepositoryPort repository) {
this.repository = repository;
}
public Experience handle(Long profileId, Command cmd) {
var experience = Experience.builder()
.id(new ExperienceId(profileId))
.company(cmd.company())
.position(cmd.position())
.period(cmd.period())
.location(cmd.location())
.description(cmd.description())
.technologies(new ArrayList<>())
.achievements(new ArrayList<>())
.build();
cmd.technologies.forEach(name -> experience.getTechnologies().add(Technology.builder().id(null).name(name).build()));
cmd.achievements.forEach(description -> experience.getAchievements().add(Achievement.builder().id(null).description(description).build()));
return repository.save(experience);
}
public record Command(
String position,
String company,
String period,
String location,
String description,
List<String> technologies,
List<String> achievements
) {
}
}

View File

@@ -0,0 +1,24 @@
package com.pablotj.portfolio.application.experience;
import com.pablotj.portfolio.domain.experience.Experience;
import com.pablotj.portfolio.domain.experience.ExperienceId;
import com.pablotj.portfolio.domain.experience.port.ExperienceRepositoryPort;
import java.util.List;
import java.util.Optional;
public class GetExperienceUseCase {
private final ExperienceRepositoryPort repository;
public GetExperienceUseCase(ExperienceRepositoryPort repository) {
this.repository = repository;
}
public Optional<Experience> byId(Long profileId, Long id) {
return repository.findById(new ExperienceId(profileId, id));
}
public List<Experience> all(Long profileId) {
return repository.findAll(profileId);
}
}

View File

@@ -0,0 +1,50 @@
package com.pablotj.portfolio.application.profile;
import com.pablotj.portfolio.domain.profile.Profile;
import com.pablotj.portfolio.domain.profile.ProfileSocialLink;
import com.pablotj.portfolio.domain.profile.port.ProfileRepositoryPort;
import java.util.List;
public class CreateProfileUseCase {
private final ProfileRepositoryPort repository;
public CreateProfileUseCase(ProfileRepositoryPort repository) {
this.repository = repository;
}
public Profile handle(Command cmd) {
var personalBuilder = Profile.builder()
.id(null)
.slug(cmd.slug())
.name(cmd.name())
.title(cmd.title())
.subtitle(cmd.subtitle())
.email(cmd.email())
.phone(cmd.phone())
.location(cmd.location())
.avatar(cmd.avatar())
.bio(cmd.bio());
if (cmd.socialLinks != null) {
cmd.socialLinks.forEach(l -> personalBuilder.social(ProfileSocialLink.builder().id(null).platform(l.platform()).url(l.url()).build()));
}
return repository.save(personalBuilder.build());
}
public record Command(
String slug,
String name,
String title,
String subtitle,
String email,
String phone,
String location,
String avatar,
String bio,
List<Link> socialLinks
) {
}
public record Link(String platform, String url) {
}
}

View File

@@ -0,0 +1,30 @@
package com.pablotj.portfolio.application.profile;
import com.pablotj.portfolio.domain.profile.Profile;
import com.pablotj.portfolio.domain.profile.ProfileId;
import com.pablotj.portfolio.domain.profile.port.ProfileRepositoryPort;
import java.util.List;
import java.util.Optional;
public class GetProfileUseCase {
private final ProfileRepositoryPort repository;
public GetProfileUseCase(ProfileRepositoryPort repository) {
this.repository = repository;
}
public Optional<Profile> byId(Long id) {
return repository.findById(new ProfileId(id));
}
public Optional<Profile> bySlug(String slug) {
return repository.findBySlug(new ProfileId(slug));
}
public List<Profile> all() {
return repository.findAll();
}
}

View File

@@ -1,25 +1,44 @@
package com.pablotj.portfolio.application.project; package com.pablotj.portfolio.application.project;
import com.pablotj.portfolio.domain.project.Project; import com.pablotj.portfolio.domain.project.Project;
import com.pablotj.portfolio.domain.project.ProjectFeature;
import com.pablotj.portfolio.domain.project.ProjectId;
import com.pablotj.portfolio.domain.project.ProjectTechnology;
import com.pablotj.portfolio.domain.project.port.ProjectRepositoryPort; import com.pablotj.portfolio.domain.project.port.ProjectRepositoryPort;
import java.util.ArrayList;
import java.util.List;
public class CreateProjectUseCase { public class CreateProjectUseCase {
private final ProjectRepositoryPort repository; private final ProjectRepositoryPort repository;
public record Command(String title, String description, String url) {}
public CreateProjectUseCase(ProjectRepositoryPort repository) { public CreateProjectUseCase(ProjectRepositoryPort repository) {
this.repository = repository; this.repository = repository;
} }
public Project handle(Command cmd) { public Project handle(Long profileId, Command cmd) {
var project = Project.builder() var project = Project.builder()
.id(null) .id(new ProjectId(profileId))
.title(cmd.title()) .title(cmd.title())
.description(cmd.description()) .description(cmd.description())
.url(cmd.url()) .image(cmd.image())
.technologies(new ArrayList<>())
.features(new ArrayList<>())
.demo(cmd.demo())
.repository(cmd.repository())
.build(); .build();
cmd.technologies.forEach(name -> project.getTechnologies().add(ProjectTechnology.builder().id(null).name(name).build()));
cmd.features.forEach(description -> project.getFeatures().add(ProjectFeature.builder().id(null).name(description).build()));
return repository.save(project); return repository.save(project);
} }
public record Command(String title,
String description,
String image,
List<String> technologies,
List<String> features,
String demo,
String repository) {
}
} }

View File

@@ -3,7 +3,6 @@ package com.pablotj.portfolio.application.project;
import com.pablotj.portfolio.domain.project.Project; import com.pablotj.portfolio.domain.project.Project;
import com.pablotj.portfolio.domain.project.ProjectId; import com.pablotj.portfolio.domain.project.ProjectId;
import com.pablotj.portfolio.domain.project.port.ProjectRepositoryPort; import com.pablotj.portfolio.domain.project.port.ProjectRepositoryPort;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -15,11 +14,11 @@ public class GetProjectUseCase {
this.repository = repository; this.repository = repository;
} }
public Optional<Project> byId(Long id) { public Optional<Project> byId(Long profileId, Long id) {
return repository.findById(new ProjectId(id)); return repository.findById(new ProjectId(profileId, id));
} }
public List<Project> all() { public List<Project> all(Long profileId) {
return repository.findAll(); return repository.findAll(profileId);
} }
} }

View File

@@ -0,0 +1,43 @@
package com.pablotj.portfolio.application.skill;
import com.pablotj.portfolio.domain.skill.Skill;
import com.pablotj.portfolio.domain.skill.SkillGroup;
import com.pablotj.portfolio.domain.skill.SkillGroupId;
import com.pablotj.portfolio.domain.skill.port.SkillRepositoryPort;
import java.util.ArrayList;
import java.util.List;
public class CreateSkillUseCase {
private final SkillRepositoryPort repository;
public CreateSkillUseCase(SkillRepositoryPort repository) {
this.repository = repository;
}
public SkillGroup handle(Long profileId, CommandGroup cmd) {
var skillGroup = SkillGroup.builder()
.id(new SkillGroupId(profileId))
.name(cmd.name())
.icon(cmd.icon())
.skills(new ArrayList<>())
.build();
cmd.skills.forEach(s -> skillGroup.getSkills().add(
Skill.builder().id(null).name(s.name).level(s.level).years(s.years).build()));
return repository.save(skillGroup);
}
public record CommandGroup(
String name,
String icon,
List<CommandSkill> skills
) {
}
public record CommandSkill(
String name,
Integer level,
Integer years
) {
}
}

View File

@@ -0,0 +1,24 @@
package com.pablotj.portfolio.application.skill;
import com.pablotj.portfolio.domain.skill.SkillGroup;
import com.pablotj.portfolio.domain.skill.SkillGroupId;
import com.pablotj.portfolio.domain.skill.port.SkillRepositoryPort;
import java.util.List;
import java.util.Optional;
public class GetSkillUseCase {
private final SkillRepositoryPort repository;
public GetSkillUseCase(SkillRepositoryPort repository) {
this.repository = repository;
}
public Optional<SkillGroup> byId(Long profileId, Long id) {
return repository.findById(new SkillGroupId(profileId, id));
}
public List<SkillGroup> all(Long profileId) {
return repository.findAll(profileId);
}
}

View File

@@ -1,10 +1,14 @@
<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> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>com.pablotj</groupId> <groupId>com.pablotj</groupId>
<artifactId>portfolio-api</artifactId> <artifactId>portfolio-api</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
</parent> </parent>
<artifactId>bootstrap</artifactId> <artifactId>bootstrap</artifactId>
<dependencies> <dependencies>
@@ -14,41 +18,85 @@
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<!-- Drivers DB -->
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.apache.logging.log4j</groupId>
<artifactId>postgresql</artifactId> <artifactId>log4j-api</artifactId>
<scope>runtime</scope> <version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Swagger/OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Actuator -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<build>
<plugins>
<plugin>
<groupId>io.jooby</groupId>
<artifactId>jooby-maven-plugin</artifactId>
<version>${jooby.version}</version>
<executions>
<execution>
<goals>
<goal>openapi</goal>
</goals>
</execution>
</executions>
<configuration>
<mainClass>com.pablotj.portfolio.bootstrap.PortfolioApplication</mainClass>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<id>jooby-shade</id>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>META-INF/MANIFEST.MF</exclude>
<exclude>META-INF/DEPENDENCIES*</exclude>
<exclude>META-INF/LICENSE*</exclude>
<exclude>META-INF/NOTICE*</exclude>
<exclude>META-INF/*.txt</exclude>
<exclude>META-INF/*.md</exclude>
<exclude>META-INF/io.netty.versions.properties</exclude>
<exclude>draftv3/schema</exclude>
<exclude>draftv4/schema</exclude>
<exclude>**/module-info.class</exclude>
<exclude>META-INF/versions/**</exclude>
<exclude>LICENSE*</exclude>
<exclude>NOTICE*</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer
implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/ebean.mf</resource>
</transformer>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.pablotj.portfolio.bootstrap.PortfolioApplication</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project> </project>

View File

@@ -1,16 +1,67 @@
package com.pablotj.portfolio.bootstrap; package com.pablotj.portfolio.bootstrap;
import org.springframework.boot.SpringApplication; import com.pablotj.portfolio.bootstrap.certification.CertificationApplicationConfig;
import org.springframework.boot.autoconfigure.SpringBootApplication; import com.pablotj.portfolio.bootstrap.education.EducationApplicationConfig;
import org.springframework.boot.autoconfigure.domain.EntityScan; import com.pablotj.portfolio.bootstrap.experience.ExperienceApplicationConfig;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import com.pablotj.portfolio.bootstrap.profile.ProfileApplicationConfig;
import com.pablotj.portfolio.bootstrap.project.ProjectApplicationConfig;
import com.pablotj.portfolio.bootstrap.skill.SkillApplicationConfig;
import com.pablotj.portfolio.infrastructure.config.CorsConfig;
import com.pablotj.portfolio.infrastructure.rest.api.ApiErrorController;
import com.pablotj.portfolio.infrastructure.rest.api.ApiRootController;
import com.pablotj.portfolio.infrastructure.rest.certification.CertificationController;
import com.pablotj.portfolio.infrastructure.rest.education.EducationController;
import com.pablotj.portfolio.infrastructure.rest.experience.ExperienceController;
import com.pablotj.portfolio.infrastructure.rest.profile.ProfileController;
import com.pablotj.portfolio.infrastructure.rest.project.ProjectController;
import com.pablotj.portfolio.infrastructure.rest.skill.SkillController;
import io.jooby.Jooby;
import io.jooby.OpenAPIModule;
import io.jooby.ebean.EbeanModule;
import io.jooby.flyway.FlywayModule;
import io.jooby.guice.GuiceModule;
import io.jooby.hibernate.validator.HibernateValidatorModule;
import io.jooby.hikari.HikariModule;
import io.jooby.jackson.JacksonModule;
import io.jooby.netty.NettyServer;
@SpringBootApplication(scanBasePackages = "com.pablotj") public class PortfolioApplication extends Jooby {
@EnableJpaRepositories(basePackages = "com.pablotj.portfolio.infrastructure.persistence.repo")
@EntityScan(basePackages = "com.pablotj.portfolio.infrastructure.persistence.entity")
public class PortfolioApplication {
public static void main(String[] args) { {
SpringApplication.run(PortfolioApplication.class, args); install(new NettyServer());
install(new JacksonModule());
install(new HibernateValidatorModule());
install(new HikariModule());
install(new FlywayModule());
install(new EbeanModule());
install(new CorsConfig());
error(ApiErrorController.getHandler());
install(new ProfileApplicationConfig());
install(new ProjectApplicationConfig());
install(new CertificationApplicationConfig());
install(new EducationApplicationConfig());
install(new ExperienceApplicationConfig());
install(new SkillApplicationConfig());
install(new GuiceModule());
path("/api", () -> {
mvc(ApiRootController.class);
mvc(CertificationController.class);
mvc(EducationController.class);
mvc(ExperienceController.class);
mvc(ProfileController.class);
mvc(ProjectController.class);
mvc(SkillController.class);
});
install(new OpenAPIModule().swaggerUI("/docs"));
}
public static void main(final String[] args) {
runApp(args, PortfolioApplication::new);
} }
} }

View File

@@ -0,0 +1,22 @@
package com.pablotj.portfolio.bootstrap.certification;
import com.pablotj.portfolio.application.certification.CreateCertificationUseCase;
import com.pablotj.portfolio.application.certification.GetCertificationUseCase;
import com.pablotj.portfolio.domain.certification.port.CertificationRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.certification.adapter.CertificationRepositoryAdapter;
import io.jooby.Extension;
import io.jooby.Jooby;
import jakarta.annotation.Nonnull;
public class CertificationApplicationConfig implements Extension {
@Override
public void install(@Nonnull Jooby app) {
CertificationRepositoryAdapter adapter = new CertificationRepositoryAdapter();
app.getServices().put(CertificationRepositoryPort.class, adapter);
app.getServices().put(GetCertificationUseCase.class, new GetCertificationUseCase(adapter));
app.getServices().put(CreateCertificationUseCase.class, new CreateCertificationUseCase(adapter));
}
}

View File

@@ -0,0 +1,22 @@
package com.pablotj.portfolio.bootstrap.education;
import com.pablotj.portfolio.application.education.CreateEducationUseCase;
import com.pablotj.portfolio.application.education.GetEducationUseCase;
import com.pablotj.portfolio.domain.education.port.EducationRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.education.adapter.EducationRepositoryAdapter;
import io.jooby.Extension;
import io.jooby.Jooby;
import jakarta.annotation.Nonnull;
public class EducationApplicationConfig implements Extension {
@Override
public void install(@Nonnull Jooby app) {
EducationRepositoryAdapter adapter = new EducationRepositoryAdapter();
app.getServices().put(EducationRepositoryPort.class, adapter);
app.getServices().put(GetEducationUseCase.class, new GetEducationUseCase(adapter));
app.getServices().put(CreateEducationUseCase.class, new CreateEducationUseCase(adapter));
}
}

View File

@@ -0,0 +1,22 @@
package com.pablotj.portfolio.bootstrap.experience;
import com.pablotj.portfolio.application.experience.CreateExperienceUseCase;
import com.pablotj.portfolio.application.experience.GetExperienceUseCase;
import com.pablotj.portfolio.domain.experience.port.ExperienceRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.experience.adapter.ExperienceRepositoryAdapter;
import io.jooby.Extension;
import io.jooby.Jooby;
import jakarta.annotation.Nonnull;
public class ExperienceApplicationConfig implements Extension {
@Override
public void install(@Nonnull Jooby app) {
ExperienceRepositoryAdapter adapter = new ExperienceRepositoryAdapter();
app.getServices().put(ExperienceRepositoryPort.class, adapter);
app.getServices().put(GetExperienceUseCase.class, new GetExperienceUseCase(adapter));
app.getServices().put(CreateExperienceUseCase.class, new CreateExperienceUseCase(adapter));
}
}

View File

@@ -0,0 +1,22 @@
package com.pablotj.portfolio.bootstrap.profile;
import com.pablotj.portfolio.application.profile.CreateProfileUseCase;
import com.pablotj.portfolio.application.profile.GetProfileUseCase;
import com.pablotj.portfolio.domain.profile.port.ProfileRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.profile.adapter.ProfileRepositoryAdapter;
import io.jooby.Extension;
import io.jooby.Jooby;
import jakarta.annotation.Nonnull;
public class ProfileApplicationConfig implements Extension {
@Override
public void install(@Nonnull Jooby app) {
ProfileRepositoryAdapter adapter = new ProfileRepositoryAdapter();
app.getServices().put(ProfileRepositoryPort.class, adapter);
app.getServices().put(GetProfileUseCase.class, new GetProfileUseCase(adapter));
app.getServices().put(CreateProfileUseCase.class, new CreateProfileUseCase(adapter));
}
}

View File

@@ -3,19 +3,20 @@ package com.pablotj.portfolio.bootstrap.project;
import com.pablotj.portfolio.application.project.CreateProjectUseCase; import com.pablotj.portfolio.application.project.CreateProjectUseCase;
import com.pablotj.portfolio.application.project.GetProjectUseCase; import com.pablotj.portfolio.application.project.GetProjectUseCase;
import com.pablotj.portfolio.domain.project.port.ProjectRepositoryPort; import com.pablotj.portfolio.domain.project.port.ProjectRepositoryPort;
import org.springframework.context.annotation.Bean; import com.pablotj.portfolio.infrastructure.persistence.project.adapter.ProjectRepositoryAdapter;
import org.springframework.context.annotation.Configuration; import io.jooby.Extension;
import io.jooby.Jooby;
import jakarta.annotation.Nonnull;
@Configuration public class ProjectApplicationConfig implements Extension {
public class ProjectApplicationConfig { @Override
public void install(@Nonnull Jooby app) {
@Bean ProjectRepositoryAdapter adapter = new ProjectRepositoryAdapter();
public CreateProjectUseCase createProjectUseCase(ProjectRepositoryPort repo) {
return new CreateProjectUseCase(repo);
}
@Bean app.getServices().put(ProjectRepositoryPort.class, adapter);
public GetProjectUseCase getProjectUseCase(ProjectRepositoryPort repo) {
return new GetProjectUseCase(repo); app.getServices().put(CreateProjectUseCase.class, new CreateProjectUseCase(adapter));
app.getServices().put(GetProjectUseCase.class, new GetProjectUseCase(adapter));
} }
} }

View File

@@ -0,0 +1,22 @@
package com.pablotj.portfolio.bootstrap.skill;
import com.pablotj.portfolio.application.skill.CreateSkillUseCase;
import com.pablotj.portfolio.application.skill.GetSkillUseCase;
import com.pablotj.portfolio.domain.skill.port.SkillRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.skill.adapter.SkillRepositoryAdapter;
import io.jooby.Extension;
import io.jooby.Jooby;
import jakarta.annotation.Nonnull;
public class SkillApplicationConfig implements Extension {
@Override
public void install(@Nonnull Jooby app) {
SkillRepositoryAdapter adapter = new SkillRepositoryAdapter();
app.getServices().put(SkillRepositoryPort.class, adapter);
app.getServices().put(GetSkillUseCase.class, new GetSkillUseCase(adapter));
app.getServices().put(CreateSkillUseCase.class, new CreateSkillUseCase(adapter));
}
}

View File

@@ -0,0 +1,60 @@
# Configuración de la App (Sustituye a info y app)
info {
app {
version = "0.0.1-SNAPSHOT" # Maven no rellena esto automáticamente en Jooby sin plugins extra, mejor ponlo fijo
}
}
app {
cors {
allowed-origins = ${?APP_ALLOWED_ORIGINS}
}
}
# Configuración del Servidor
server {
port = 8080
# En Jooby, el context-path se define habitualmente en la clase App,
# pero puedes usar esta propiedad si la gestionas manualmente.
}
# Base de Datos (Ebean usa estas propiedades automáticamente)
db {
url = "jdbc:postgresql://"${?DB_HOST}":"${?DB_PORT}"/"${?DB_NAME}
user = ${?DB_USER}
password = ${?DB_PASSWORD}
driverClassName = org.postgresql.Driver
# HikariCP (Configuración de Pool)
hikari {
maximumPoolSize = 3
minimumIdle = 1
idleTimeout = 30000
connectionTimeout = 10000
}
}
flyway {
# Ubicación de tus scripts .sql
locations = ["classpath:db/migration"]
cleanDisabled = true
baselineOnMigrate = true
baselineVersion = ${?FLYWAY_BASELINE_VERSION}
# Si quieres que se ejecute siempre al arrancar
run = [migrate]
}
ebean {
ddl {
generate = false
run = false
}
# Mostrar SQL en consola
debug = true
}
# Jackson
jackson {
indent_output = true
}

View File

@@ -1,49 +0,0 @@
spring:
application:
name: portfolio-api
jpa:
hibernate:
ddl-auto: update
properties:
hibernate.transaction.jta.platform: org.hibernate.engine.transaction.jta.platform.internal.NoJtaPlatform
hibernate:
format_sql: true
show-sql: true
server:
port: 8080
---
spring:
config:
activate:
on-profile: default
datasource:
url: jdbc:h2:mem:portfolio_db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
---
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:portfolio}
username: ${DB_USER:postgres}
password: ${DB_PASSWORD:postgres}
driver-class-name: org.postgresql.Driver
jpa:
hibernate:
ddl-auto: validate

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
services:
db:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
ports:
- "5432:5432"
networks:
network:
volumes:
- db_data:/var/lib/postgresql/data
api:
build: .
restart: unless-stopped
ports:
- "8095:8080"
networks:
network:
environment:
DB_NAME: ${DB_NAME}
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
APP_ALLOWED_ORIGINS: ${APP_ALLOWED_ORIGINS}
depends_on:
- db
volumes:
db_data:
networks:
network:
driver: bridge

View File

@@ -10,11 +10,14 @@
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<optional>true</optional> <version>1.18.36</version>
<scope>provided</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>jakarta.validation</groupId> <groupId>org.apache.logging.log4j</groupId>
<artifactId>jakarta.validation-api</artifactId> <artifactId>log4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency> </dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -0,0 +1,14 @@
package com.pablotj.portfolio.domain.certification;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Certification {
private final CertificationId id;
private final String name;
private final String issuer;
private final String date;
private final String credentialId;
}

View File

@@ -0,0 +1,13 @@
package com.pablotj.portfolio.domain.certification;
public record CertificationId(Long profileId, Long certificationId) {
public CertificationId(Long profileId) {
this(profileId, null);
}
public CertificationId {
if (certificationId != null && certificationId < 0)
throw new IllegalArgumentException("CertificationId must be positive");
}
}

View File

@@ -0,0 +1,14 @@
package com.pablotj.portfolio.domain.certification.port;
import com.pablotj.portfolio.domain.certification.Certification;
import com.pablotj.portfolio.domain.certification.CertificationId;
import java.util.List;
import java.util.Optional;
public interface CertificationRepositoryPort {
Certification save(Certification p);
Optional<Certification> findById(CertificationId id);
List<Certification> findAll(Long profileId);
}

View File

@@ -0,0 +1,15 @@
package com.pablotj.portfolio.domain.education;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Education {
private final EducationId id;
private final String institution;
private final String degree;
private final String period;
private final String grade;
private final String description;
}

View File

@@ -0,0 +1,11 @@
package com.pablotj.portfolio.domain.education;
public record EducationId(Long profileId, Long educationId) {
public EducationId(Long profileId) {
this(profileId, null);
}
public EducationId {
if (educationId != null && educationId < 0) throw new IllegalArgumentException("EducationId must be positive");
}
}

View File

@@ -0,0 +1,14 @@
package com.pablotj.portfolio.domain.education.port;
import com.pablotj.portfolio.domain.education.Education;
import com.pablotj.portfolio.domain.education.EducationId;
import java.util.List;
import java.util.Optional;
public interface EducationRepositoryPort {
Education save(Education p);
Optional<Education> findById(EducationId id);
List<Education> findAll(Long profileId);
}

View File

@@ -0,0 +1,11 @@
package com.pablotj.portfolio.domain.experience;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Achievement {
private final ArchivementId id;
private String description;
}

View File

@@ -0,0 +1,7 @@
package com.pablotj.portfolio.domain.experience;
public record ArchivementId(Long value) {
public ArchivementId {
if (value != null && value < 0) throw new IllegalArgumentException("ArchivementId must be positive");
}
}

View File

@@ -0,0 +1,18 @@
package com.pablotj.portfolio.domain.experience;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Experience {
private final ExperienceId id;
private final String company;
private final String position;
private final String period;
private final String location;
private final String description;
private final List<Technology> technologies;
private final List<Achievement> achievements;
}

View File

@@ -0,0 +1,13 @@
package com.pablotj.portfolio.domain.experience;
public record ExperienceId(Long profileId, Long experienceId) {
public ExperienceId(Long profileId) {
this(profileId, null);
}
public ExperienceId {
if (experienceId != null && experienceId < 0)
throw new IllegalArgumentException("ProfileSocialLinkId must be positive");
}
}

View File

@@ -0,0 +1,11 @@
package com.pablotj.portfolio.domain.experience;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Technology {
private final TechnologyId id;
private String name;
}

View File

@@ -0,0 +1,7 @@
package com.pablotj.portfolio.domain.experience;
public record TechnologyId(Long value) {
public TechnologyId {
if (value != null && value < 0) throw new IllegalArgumentException("TechnologyId must be positive");
}
}

View File

@@ -0,0 +1,15 @@
package com.pablotj.portfolio.domain.experience.port;
import com.pablotj.portfolio.domain.experience.Experience;
import com.pablotj.portfolio.domain.experience.ExperienceId;
import java.util.List;
import java.util.Optional;
public interface ExperienceRepositoryPort {
Experience save(Experience p);
Optional<Experience> findById(ExperienceId id);
List<Experience> findAll(Long profileId);
}

View File

@@ -0,0 +1,23 @@
package com.pablotj.portfolio.domain.profile;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
import lombok.Singular;
@Getter
@Builder
public class Profile {
private final ProfileId id;
private final String slug;
private final String name;
private final String title;
private final String subtitle;
private final String email;
private final String phone;
private final String location;
private final String avatar;
private final String bio;
@Singular("social")
private final List<ProfileSocialLink> social;
}

View File

@@ -0,0 +1,16 @@
package com.pablotj.portfolio.domain.profile;
public record ProfileId(Long id, String slug) {
public ProfileId(Long id) {
this(id, null);
}
public ProfileId(String slug) {
this(null, slug);
}
public ProfileId {
if (id != null && id < 0) throw new IllegalArgumentException("ProfileId must be positive");
}
}

View File

@@ -0,0 +1,12 @@
package com.pablotj.portfolio.domain.profile;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ProfileSocialLink {
private final ProfileSocialLinkId id;
private final String url;
private final String platform;
}

View File

@@ -0,0 +1,7 @@
package com.pablotj.portfolio.domain.profile;
public record ProfileSocialLinkId(Long value) {
public ProfileSocialLinkId {
if (value != null && value < 0) throw new IllegalArgumentException("ProfileSocialLinkId must be positive");
}
}

View File

@@ -0,0 +1,16 @@
package com.pablotj.portfolio.domain.profile.port;
import com.pablotj.portfolio.domain.profile.Profile;
import com.pablotj.portfolio.domain.profile.ProfileId;
import java.util.List;
import java.util.Optional;
public interface ProfileRepositoryPort {
Profile save(Profile p);
Optional<Profile> findBySlug(ProfileId id);
Optional<Profile> findById(ProfileId id);
List<Profile> findAll();
}

View File

@@ -1,5 +1,6 @@
package com.pablotj.portfolio.domain.project; package com.pablotj.portfolio.domain.project;
import java.util.List;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
@@ -9,5 +10,9 @@ public class Project {
private final ProjectId id; private final ProjectId id;
private final String title; private final String title;
private final String description; private final String description;
private final String url; private final String image;
private final List<ProjectTechnology> technologies;
private final List<ProjectFeature> features;
private final String demo;
private final String repository;
} }

View File

@@ -0,0 +1,11 @@
package com.pablotj.portfolio.domain.project;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ProjectFeature {
private final ProjectFeatureId id;
private final String name;
}

View File

@@ -0,0 +1,7 @@
package com.pablotj.portfolio.domain.project;
public record ProjectFeatureId(Long value) {
public ProjectFeatureId {
if (value != null && value < 0) throw new IllegalArgumentException("ProjectFeatureId must be positive");
}
}

View File

@@ -1,7 +1,12 @@
package com.pablotj.portfolio.domain.project; package com.pablotj.portfolio.domain.project;
public record ProjectId(Long value) { public record ProjectId(Long profileId, Long projectId) {
public ProjectId(Long profileId) {
this(profileId, null);
}
public ProjectId { public ProjectId {
if (value != null && value < 0) throw new IllegalArgumentException("ProjectId must be positive"); if (projectId != null && projectId < 0) throw new IllegalArgumentException("ProjectId must be positive");
} }
} }

View File

@@ -0,0 +1,11 @@
package com.pablotj.portfolio.domain.project;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ProjectTechnology {
private final ProjectTechnologyId id;
private final String name;
}

View File

@@ -0,0 +1,7 @@
package com.pablotj.portfolio.domain.project;
public record ProjectTechnologyId(Long value) {
public ProjectTechnologyId {
if (value != null && value < 0) throw new IllegalArgumentException("ProjectTechnologyId must be positive");
}
}

View File

@@ -2,12 +2,13 @@ package com.pablotj.portfolio.domain.project.port;
import com.pablotj.portfolio.domain.project.Project; import com.pablotj.portfolio.domain.project.Project;
import com.pablotj.portfolio.domain.project.ProjectId; import com.pablotj.portfolio.domain.project.ProjectId;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface ProjectRepositoryPort { public interface ProjectRepositoryPort {
Project save(Project p); Project save(Project p);
Optional<Project> findById(ProjectId id); Optional<Project> findById(ProjectId id);
List<Project> findAll();
List<Project> findAll(Long profileId);
} }

View File

@@ -0,0 +1,13 @@
package com.pablotj.portfolio.domain.skill;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Skill {
private final SkillId id;
private final String name;
private final Integer level;
private final Integer years;
}

View File

@@ -0,0 +1,14 @@
package com.pablotj.portfolio.domain.skill;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class SkillGroup {
private final SkillGroupId id;
private final String name;
private final String icon;
private final List<Skill> skills;
}

View File

@@ -0,0 +1,12 @@
package com.pablotj.portfolio.domain.skill;
public record SkillGroupId(Long profileId, Long skillGroupId) {
public SkillGroupId(Long profileId) {
this(profileId, null);
}
public SkillGroupId {
if (skillGroupId != null && skillGroupId < 0) throw new IllegalArgumentException("SkillId must be positive");
}
}

View File

@@ -0,0 +1,7 @@
package com.pablotj.portfolio.domain.skill;
public record SkillId(Long value) {
public SkillId {
if (value != null && value < 0) throw new IllegalArgumentException("SkillId must be positive");
}
}

View File

@@ -0,0 +1,14 @@
package com.pablotj.portfolio.domain.skill.port;
import com.pablotj.portfolio.domain.skill.SkillGroup;
import com.pablotj.portfolio.domain.skill.SkillGroupId;
import java.util.List;
import java.util.Optional;
public interface SkillRepositoryPort {
SkillGroup save(SkillGroup p);
Optional<SkillGroup> findById(SkillGroupId id);
List<SkillGroup> findAll(Long profileId);
}

View File

@@ -1,49 +1,158 @@
<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> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>com.pablotj</groupId> <groupId>com.pablotj</groupId>
<artifactId>portfolio-api</artifactId> <artifactId>portfolio-api</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
</parent> </parent>
<artifactId>infrastructure</artifactId> <artifactId>infrastructure</artifactId>
<properties>
<ebean.version>15.8.0</ebean.version>
<postgresql.version>42.7.2</postgresql.version>
<mapstruct.version>1.6.3</mapstruct.version>
<lombok.version>1.18.36</lombok.version>
<testcontainers.version>1.19.7</testcontainers.version>
</properties>
<dependencies> <dependencies>
<dependency>
<groupId>com.pablotj</groupId>
<artifactId>domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>com.pablotj</groupId> <groupId>com.pablotj</groupId>
<artifactId>application</artifactId> <artifactId>application</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<!-- Spring Boot -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>io.jooby</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>jooby</artifactId>
</dependency> <version>${jooby.version}</version>
<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>
</dependency> </dependency>
<!-- MapStruct --> <dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-slf4j2-impl</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>${slf4j.version}</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-netty</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-ebean</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-hikari</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-flyway</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-jackson</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-hibernate-validator</artifactId>
<version>${jooby.version}</version>
</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>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-openapi</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-swagger-ui</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency>
<groupId>io.jooby</groupId>
<artifactId>jooby-guice</artifactId>
<version>${jooby.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.mapstruct</groupId> <groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId> <artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency> </dependency>
<!-- Lombok -->
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<optional>true</optional> <version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
<build>
<plugins>
<plugin>
<groupId>io.ebean</groupId>
<artifactId>ebean-maven-plugin</artifactId>
<version>${ebean.version}</version>
<executions>
<execution>
<phase>process-classes</phase>
<goals>
<goal>enhance</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project> </project>

View File

@@ -0,0 +1,26 @@
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;
public class CorsConfig implements Extension {
@Override
public void install(@Nonnull Jooby app) {
String allowedOriginsString = app.getConfig().getString("app.cors.allowed-origins");
List<String> allowedOrigins = Arrays.asList(allowedOriginsString.split(","));
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);
app.use(new CorsHandler(cors));
}
}

View File

@@ -0,0 +1,13 @@
package com.pablotj.portfolio.infrastructure.config;
import io.jooby.Extension;
import io.jooby.Jooby;
import jakarta.annotation.Nonnull;
public class SecurityConfig implements Extension {
@Override
public void install(@Nonnull Jooby app) {
}
}

View File

@@ -1,41 +0,0 @@
package com.pablotj.portfolio.infrastructure.persistence.adapter;
import com.pablotj.portfolio.domain.project.Project;
import com.pablotj.portfolio.domain.project.ProjectId;
import com.pablotj.portfolio.domain.project.port.ProjectRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.entity.ProjectJpaEntity;
import com.pablotj.portfolio.infrastructure.persistence.mapper.ProjectJpaMapper;
import com.pablotj.portfolio.infrastructure.persistence.repo.SpringDataProjectRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class ProjectRepositoryAdapter implements ProjectRepositoryPort {
private final SpringDataProjectRepository repo;
private final ProjectJpaMapper mapper;
public ProjectRepositoryAdapter(SpringDataProjectRepository repo, ProjectJpaMapper mapper) {
this.repo = repo;
this.mapper = mapper;
}
@Override
public Project save(Project p) {
ProjectJpaEntity entity = mapper.toEntity(p);
ProjectJpaEntity saved = repo.save(entity);
return mapper.toDomain(saved);
}
@Override
public Optional<Project> findById(ProjectId id) {
return repo.findById(id.value()).map(mapper::toDomain);
}
@Override
public List<Project> findAll() {
return repo.findAll().stream().map(mapper::toDomain).toList();
}
}

View File

@@ -0,0 +1,47 @@
package com.pablotj.portfolio.infrastructure.persistence.certification.adapter;
import com.pablotj.portfolio.domain.certification.Certification;
import com.pablotj.portfolio.domain.certification.CertificationId;
import com.pablotj.portfolio.domain.certification.port.CertificationRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.certification.entity.CertificationEntity;
import com.pablotj.portfolio.infrastructure.persistence.certification.mapper.CertificationEntityMapper;
import io.ebean.DB;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.mapstruct.factory.Mappers;
@Singleton
public class CertificationRepositoryAdapter implements CertificationRepositoryPort {
private static final CertificationEntityMapper MAPPER = Mappers.getMapper(CertificationEntityMapper.class);
@Override
public Certification save(Certification p) {
CertificationEntity entity = MAPPER.toEntity(p);
DB.save(entity);
return MAPPER.toDomain(entity);
}
@Override
public Optional<Certification> findById(CertificationId id) {
CertificationEntity entity = DB.find(CertificationEntity.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 DB.find(CertificationEntity.class)
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(MAPPER::toDomain)
.toList();
}
}

View File

@@ -0,0 +1,42 @@
package com.pablotj.portfolio.infrastructure.persistence.certification.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileEntity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "CERTIFICATION")
@Getter
@Setter
public class CertificationEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", nullable = false)
private ProfileEntity profile;
@Column
private String name;
@Column
private String issuer;
@Column
private String date;
@Column
private String credentialId;
}

View File

@@ -0,0 +1,17 @@
package com.pablotj.portfolio.infrastructure.persistence.certification.mapper;
import com.pablotj.portfolio.domain.certification.Certification;
import com.pablotj.portfolio.infrastructure.persistence.certification.entity.CertificationEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface CertificationEntityMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "profile.id", source = "id.profileId")
CertificationEntity toEntity(Certification domain);
@Mapping(target = "id", expression = "java(new CertificationId(e.getProfile().getId(), e.getId()))")
Certification toDomain(CertificationEntity e);
}

View File

@@ -0,0 +1,47 @@
package com.pablotj.portfolio.infrastructure.persistence.education.adapter;
import com.pablotj.portfolio.domain.education.Education;
import com.pablotj.portfolio.domain.education.EducationId;
import com.pablotj.portfolio.domain.education.port.EducationRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.education.entity.EducationEntity;
import com.pablotj.portfolio.infrastructure.persistence.education.mapper.EducationEntityMapper;
import io.ebean.DB;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.mapstruct.factory.Mappers;
@Singleton
public class EducationRepositoryAdapter implements EducationRepositoryPort {
private static final EducationEntityMapper MAPPER = Mappers.getMapper(EducationEntityMapper.class);
@Override
public Education save(Education p) {
EducationEntity entity = MAPPER.toEntity(p);
DB.save(entity);
return MAPPER.toDomain(entity);
}
@Override
public Optional<Education> findById(EducationId id) {
EducationEntity entity = DB.find(EducationEntity.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 DB.find(EducationEntity.class)
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(MAPPER::toDomain)
.toList();
}
}

View File

@@ -0,0 +1,45 @@
package com.pablotj.portfolio.infrastructure.persistence.education.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileEntity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "EDUCATION")
@Getter
@Setter
public class EducationEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", nullable = false)
private ProfileEntity profile;
@Column
private String institution;
@Column
private String degree;
@Column
private String period;
@Column
private String grade;
@Column(columnDefinition = "text")
private String description;
}

View File

@@ -0,0 +1,17 @@
package com.pablotj.portfolio.infrastructure.persistence.education.mapper;
import com.pablotj.portfolio.domain.education.Education;
import com.pablotj.portfolio.infrastructure.persistence.education.entity.EducationEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface EducationEntityMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "profile.id", source = "id.profileId")
EducationEntity toEntity(Education domain);
@Mapping(target = "id", expression = "java(new EducationId(e.getProfile().getId(), e.getId()))")
Education toDomain(EducationEntity e);
}

View File

@@ -1,23 +0,0 @@
package com.pablotj.portfolio.infrastructure.persistence.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "projects")
@Getter @Setter
public class ProjectJpaEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "text")
private String description;
private String url;
}

View File

@@ -0,0 +1,51 @@
package com.pablotj.portfolio.infrastructure.persistence.experience.adapter;
import com.pablotj.portfolio.domain.experience.Experience;
import com.pablotj.portfolio.domain.experience.ExperienceId;
import com.pablotj.portfolio.domain.experience.port.ExperienceRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.experience.entity.ExperienceEntity;
import com.pablotj.portfolio.infrastructure.persistence.experience.mapper.ExperienceEntityMapper;
import io.ebean.DB;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.mapstruct.factory.Mappers;
@Singleton
public class ExperienceRepositoryAdapter implements ExperienceRepositoryPort {
private static final ExperienceEntityMapper MAPPER = Mappers.getMapper(ExperienceEntityMapper.class);
@Override
public Experience save(Experience p) {
ExperienceEntity entity = MAPPER.toEntity(p);
DB.save(entity);
return MAPPER.toDomain(entity);
}
@Override
public Optional<Experience> findById(ExperienceId id) {
ExperienceEntity entity = DB.find(ExperienceEntity.class)
.fetch("technologies")
.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 DB.find(ExperienceEntity.class)
.fetch("technologies")
.fetch("achievements")
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(MAPPER::toDomain)
.toList();
}
}

View File

@@ -0,0 +1,32 @@
package com.pablotj.portfolio.infrastructure.persistence.experience.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "EXPERIENCE_ACHIEVEMENT")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class ExperienceAchievementEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(columnDefinition = "text")
private String description;
}

View File

@@ -0,0 +1,64 @@
package com.pablotj.portfolio.infrastructure.persistence.experience.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileEntity;
import io.ebean.Model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.List;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "EXPERIENCE")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class ExperienceEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", nullable = false)
private ProfileEntity profile;
@Column
private String position;
@Column
private String company;
@Column
private String period;
@Column
private String location;
@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<ExperienceSkillEntity> technologies;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "EXPERIENCE_ID")
private List<ExperienceAchievementEntity> achievements;
}

View File

@@ -0,0 +1,30 @@
package com.pablotj.portfolio.infrastructure.persistence.experience.entity;
import io.ebean.Model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "EXPERIENCE_SKILL")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class ExperienceSkillEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}

View File

@@ -0,0 +1,35 @@
package com.pablotj.portfolio.infrastructure.persistence.experience.mapper;
import com.pablotj.portfolio.domain.experience.Achievement;
import com.pablotj.portfolio.domain.experience.Experience;
import com.pablotj.portfolio.domain.experience.Technology;
import com.pablotj.portfolio.infrastructure.persistence.experience.entity.ExperienceAchievementEntity;
import com.pablotj.portfolio.infrastructure.persistence.experience.entity.ExperienceEntity;
import com.pablotj.portfolio.infrastructure.persistence.experience.entity.ExperienceSkillEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface ExperienceEntityMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "profile.id", source = "id.profileId")
ExperienceEntity toEntity(Experience domain);
@Mapping(target = "id", expression = "java(new ExperienceId(entity.getProfile().getId(), entity.getId()))")
Experience toDomain(ExperienceEntity entity);
@Mapping(target = "id", ignore = true)
ExperienceAchievementEntity toEntity(Achievement entity);
@Mapping(target = "id.value", source = "id")
Achievement toDomain(ExperienceAchievementEntity entity);
@Mapping(target = "id", ignore = true)
ExperienceSkillEntity toEntity(Technology entity);
@Mapping(target = "id.value", source = "id")
Technology toDomain(ExperienceSkillEntity entity);
}

View File

@@ -1,24 +0,0 @@
package com.pablotj.portfolio.infrastructure.persistence.mapper;
import com.pablotj.portfolio.domain.project.Project;
import com.pablotj.portfolio.domain.project.ProjectId;
import com.pablotj.portfolio.infrastructure.persistence.entity.ProjectJpaEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface ProjectJpaMapper {
@Mapping(target = "id", ignore = true)
ProjectJpaEntity toEntity(Project domain);
default Project toDomain(ProjectJpaEntity e) {
if (e == null) return null;
return Project.builder()
.id(e.getId() == null ? null : new ProjectId(e.getId()))
.title(e.getTitle())
.description(e.getDescription())
.url(e.getUrl())
.build();
}
}

View File

@@ -0,0 +1,58 @@
package com.pablotj.portfolio.infrastructure.persistence.profile.adapter;
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.ProfileEntity;
import com.pablotj.portfolio.infrastructure.persistence.profile.mapper.ProfileEntityMapper;
import io.ebean.DB;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.mapstruct.factory.Mappers;
@Singleton
public class ProfileRepositoryAdapter implements ProfileRepositoryPort {
private static final ProfileEntityMapper MAPPER = Mappers.getMapper(ProfileEntityMapper.class);
@Override
public Profile save(Profile p) {
ProfileEntity entity = MAPPER.toEntity(p);
DB.save(entity);
return MAPPER.toDomain(entity);
}
@Override
public Optional<Profile> findBySlug(ProfileId id) {
ProfileEntity entity = DB.find(ProfileEntity.class)
.fetch("social")
.where()
.eq("slug", id.slug())
.findOne();
return Optional.ofNullable(entity).map(MAPPER::toDomain);
}
@Override
public Optional<Profile> findById(ProfileId id) {
ProfileEntity entity = DB.find(ProfileEntity.class)
.fetch("social")
.where()
.eq("id", id.id())
.findOne();
return Optional.ofNullable(entity).map(MAPPER::toDomain);
}
@Override
public List<Profile> findAll() {
return DB.find(ProfileEntity.class)
.fetch("social")
.findList()
.stream()
.map(MAPPER::toDomain)
.toList();
}
}

View File

@@ -0,0 +1,57 @@
package com.pablotj.portfolio.infrastructure.persistence.profile.entity;
import io.ebean.Model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "profile")
@Getter
@Setter
public class ProfileEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String slug;
@Column
private String name;
@Column
private String title;
@Column
private String subtitle;
@Column
private String email;
@Column
private String phone;
@Column
private String location;
@Column
private String avatar;
@Column(columnDefinition = "text")
private String bio;
@OneToMany(mappedBy = "profile", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProfileSocialLinkEntity> social = new ArrayList<>();
}

View File

@@ -0,0 +1,35 @@
package com.pablotj.portfolio.infrastructure.persistence.profile.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "profile_social_link")
@Getter
@Setter
public class ProfileSocialLinkEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", nullable = false)
private ProfileEntity profile;
@Column
private String url;
@Column
private String platform;
}

View File

@@ -0,0 +1,26 @@
package com.pablotj.portfolio.infrastructure.persistence.profile.mapper;
import com.pablotj.portfolio.domain.profile.Profile;
import com.pablotj.portfolio.domain.profile.ProfileSocialLink;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileEntity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileSocialLinkEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface ProfileEntityMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "social", source = "social")
ProfileEntity toEntity(Profile domain);
@Mapping(target = "id.id", source = "id")
@Mapping(target = "social", source = "social")
Profile toDomain(ProfileEntity e);
@Mapping(target = "id", ignore = true)
ProfileSocialLinkEntity toEntitySocial(ProfileSocialLink entity);
@Mapping(target = "id.value", source = "id")
ProfileSocialLink toDomainSocial(ProfileSocialLinkEntity entity);
}

View File

@@ -0,0 +1,52 @@
package com.pablotj.portfolio.infrastructure.persistence.project.adapter;
import com.pablotj.portfolio.domain.project.Project;
import com.pablotj.portfolio.domain.project.ProjectId;
import com.pablotj.portfolio.domain.project.port.ProjectRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.project.entity.ProjectEntity;
import com.pablotj.portfolio.infrastructure.persistence.project.mapper.ProjectEntityMapper;
import io.ebean.DB;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.mapstruct.factory.Mappers;
@Singleton
public class ProjectRepositoryAdapter implements ProjectRepositoryPort {
private static final ProjectEntityMapper MAPPER = Mappers.getMapper(ProjectEntityMapper.class);
@Override
public Project save(Project p) {
ProjectEntity entity = MAPPER.toEntity(p);
DB.save(entity);
return MAPPER.toDomain(entity);
}
@Override
public Optional<Project> findById(ProjectId id) {
ProjectEntity entity = DB.find(ProjectEntity.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 DB.find(ProjectEntity.class)
.fetch("technologies")
.fetch("features")
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(MAPPER::toDomain)
.toList();
}
}

View File

@@ -0,0 +1,57 @@
package com.pablotj.portfolio.infrastructure.persistence.project.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileEntity;
import io.ebean.Model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "PROJECT")
@Getter
@Setter
public class ProjectEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", nullable = false)
private ProfileEntity profile;
@Column
private String title;
@Column(columnDefinition = "text")
private String description;
@Column
private String image;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "PROJECT_ID")
private List<ProjectTechnologyEntity> technologies;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "PROJECT_ID")
private List<ProjectFeatureEntity> features;
@Column
private String demo;
@Column
private String repository;
}

View File

@@ -0,0 +1,26 @@
package com.pablotj.portfolio.infrastructure.persistence.project.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "PROJECT_FEATURE")
@Getter
@Setter
public class ProjectFeatureEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
}

View File

@@ -0,0 +1,26 @@
package com.pablotj.portfolio.infrastructure.persistence.project.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "PROJECT_FEATURE_TECHNOLOGY")
@Getter
@Setter
public class ProjectTechnologyEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
}

View File

@@ -0,0 +1,33 @@
package com.pablotj.portfolio.infrastructure.persistence.project.mapper;
import com.pablotj.portfolio.domain.project.Project;
import com.pablotj.portfolio.domain.project.ProjectFeature;
import com.pablotj.portfolio.domain.project.ProjectTechnology;
import com.pablotj.portfolio.infrastructure.persistence.project.entity.ProjectEntity;
import com.pablotj.portfolio.infrastructure.persistence.project.entity.ProjectFeatureEntity;
import com.pablotj.portfolio.infrastructure.persistence.project.entity.ProjectTechnologyEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface ProjectEntityMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "profile.id", source = "id.profileId")
ProjectEntity toEntity(Project domain);
@Mapping(target = "id", expression = "java(new ProjectId(e.getProfile().getId(), e.getId()))")
Project toDomain(ProjectEntity e);
@Mapping(target = "id", ignore = true)
ProjectTechnologyEntity toEntity(ProjectTechnology entity);
@Mapping(target = "id.value", source = "id")
ProjectTechnology toDomain(ProjectTechnologyEntity entity);
@Mapping(target = "id", ignore = true)
ProjectFeatureEntity toEntity(ProjectFeature entity);
@Mapping(target = "id.value", source = "id")
ProjectFeature toDomain(ProjectFeatureEntity entity);
}

View File

@@ -1,6 +0,0 @@
package com.pablotj.portfolio.infrastructure.persistence.repo;
import com.pablotj.portfolio.infrastructure.persistence.entity.ProjectJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SpringDataProjectRepository extends JpaRepository<ProjectJpaEntity, Long> {}

View File

@@ -0,0 +1,49 @@
package com.pablotj.portfolio.infrastructure.persistence.skill.adapter;
import com.pablotj.portfolio.domain.skill.SkillGroup;
import com.pablotj.portfolio.domain.skill.SkillGroupId;
import com.pablotj.portfolio.domain.skill.port.SkillRepositoryPort;
import com.pablotj.portfolio.infrastructure.persistence.skill.entity.SkillGroupEntity;
import com.pablotj.portfolio.infrastructure.persistence.skill.mapper.SkillMapper;
import io.ebean.DB;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.Optional;
import org.mapstruct.factory.Mappers;
@Singleton
public class SkillRepositoryAdapter implements SkillRepositoryPort {
private static final SkillMapper MAPPER = Mappers.getMapper(SkillMapper.class);
@Override
public SkillGroup save(SkillGroup p) {
SkillGroupEntity entity = MAPPER.toEntity(p);
DB.save(entity);
return MAPPER.toDomain(entity);
}
@Override
public Optional<SkillGroup> findById(SkillGroupId id) {
SkillGroupEntity entity = DB.find(SkillGroupEntity.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 DB.find(SkillGroupEntity.class)
.fetch("skills")
.where()
.eq("profile.id", profileId)
.findList()
.stream()
.map(MAPPER::toDomain)
.toList();
}
}

View File

@@ -0,0 +1,31 @@
package com.pablotj.portfolio.infrastructure.persistence.skill.entity;
import io.ebean.Model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "SKILL")
@Getter
@Setter
public class SkillEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
@Column
private Integer level;
@Column
private Integer years;
}

View File

@@ -0,0 +1,43 @@
package com.pablotj.portfolio.infrastructure.persistence.skill.entity;
import com.pablotj.portfolio.infrastructure.persistence.profile.entity.ProfileEntity;
import io.ebean.Model;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
@Entity
@Table(name = "SKILL_GROUP")
@Getter
@Setter
public class SkillGroupEntity extends Model {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "profile_id", nullable = false)
private ProfileEntity profile;
@Column
private String name;
@Column
private String icon;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "SKILL_ID")
private List<SkillEntity> skills;
}

View File

@@ -0,0 +1,25 @@
package com.pablotj.portfolio.infrastructure.persistence.skill.mapper;
import com.pablotj.portfolio.domain.skill.Skill;
import com.pablotj.portfolio.domain.skill.SkillGroup;
import com.pablotj.portfolio.infrastructure.persistence.skill.entity.SkillEntity;
import com.pablotj.portfolio.infrastructure.persistence.skill.entity.SkillGroupEntity;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface SkillMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "profile.id", source = "id.profileId")
SkillGroupEntity toEntity(SkillGroup domain);
@Mapping(target = "id", expression = "java(new SkillGroupId(entity.getProfile().getId(), entity.getId()))")
SkillGroup toDomain(SkillGroupEntity entity);
@Mapping(target = "id", ignore = true)
SkillEntity toEntity(Skill entity);
@Mapping(target = "id.value", source = "id")
Skill toDomain(SkillEntity entity);
}

View File

@@ -0,0 +1,28 @@
package com.pablotj.portfolio.infrastructure.rest.api;
import io.jooby.ErrorHandler;
import java.util.LinkedHashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ApiErrorController {
private static final Logger log = LoggerFactory.getLogger(ApiErrorController.class);
public static ErrorHandler getHandler() {
return (ctx, cause, statusCode) -> {
log.error("Error en la API: {}", cause.getMessage(), cause);
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

@@ -0,0 +1,39 @@
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;
@Path("/")
public class ApiRootController {
private final String appVersion;
@Inject
public ApiRootController(@Named("info.app.version") String appVersion) {
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 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 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")
)
);
}
}

View File

@@ -0,0 +1,57 @@
package com.pablotj.portfolio.infrastructure.rest.certification;
import com.pablotj.portfolio.application.certification.CreateCertificationUseCase;
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.util.List;
import org.mapstruct.factory.Mappers;
@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;
@Inject
public CertificationController(CreateCertificationUseCase createUC, GetCertificationUseCase getUC) {
this.createUC = createUC;
this.getUC = getUC;
}
@GET
public List<CertificationDto> all(@PathParam Long profileId) {
return getUC.all(profileId).stream()
.map(MAPPER::toDto)
.toList();
}
@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)
.orElseThrow(() -> new io.jooby.exception.NotFoundException("Certification not found"));
}
@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);
return MAPPER.toDto(created);
}
}

View File

@@ -0,0 +1,8 @@
package com.pablotj.portfolio.infrastructure.rest.certification.dto;
public record CertificationDto(Long id,
String name,
String issuer,
String date,
String credentialId) {
}

View File

@@ -0,0 +1,9 @@
package com.pablotj.portfolio.infrastructure.rest.certification.dto;
public record CreateCertificationRequest(
String name,
String issuer,
String date,
String credentialId
) {
}

View File

@@ -0,0 +1,13 @@
package com.pablotj.portfolio.infrastructure.rest.certification.mapper;
import com.pablotj.portfolio.domain.certification.Certification;
import com.pablotj.portfolio.infrastructure.rest.certification.dto.CertificationDto;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface CertificationRestMapper {
@Mapping(target = "id", source = "id.certificationId")
CertificationDto toDto(Certification domain);
}

View File

@@ -1,53 +0,0 @@
package com.pablotj.portfolio.infrastructure.rest.controller;
import com.pablotj.portfolio.application.project.CreateProjectUseCase;
import com.pablotj.portfolio.application.project.GetProjectUseCase;
import com.pablotj.portfolio.infrastructure.rest.dto.CreateProjectRequest;
import com.pablotj.portfolio.infrastructure.rest.dto.ProjectDto;
import com.pablotj.portfolio.infrastructure.rest.mapper.ProjectRestMapper;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/projects")
public class ProjectController {
private final CreateProjectUseCase createUC;
private final GetProjectUseCase getUC;
private final ProjectRestMapper mapper;
public ProjectController(CreateProjectUseCase createUC, GetProjectUseCase getUC, ProjectRestMapper mapper) {
this.createUC = createUC;
this.getUC = getUC;
this.mapper = mapper;
}
@GetMapping
public List<ProjectDto> all() {
return getUC.all().stream().map(mapper::toDto).toList();
}
@GetMapping("/{id}")
public ResponseEntity<ProjectDto> byId(@PathVariable Long id) {
return getUC.byId(id)
.map(mapper::toDto)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<ProjectDto> create(@Valid @RequestBody CreateProjectRequest request) {
var cmd = new CreateProjectUseCase.Command(
request.title(),
request.description(),
request.url()
);
var created = createUC.handle(cmd);
var body = mapper.toDto(created);
return ResponseEntity.created(URI.create("/api/projects/" + body.id())).body(body);
}
}

View File

@@ -1,9 +0,0 @@
package com.pablotj.portfolio.infrastructure.rest.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateProjectRequest(
@NotBlank String title,
String description,
String url
) {}

Some files were not shown because too many files have changed in this diff Show More