diff --git a/.gitignore b/.gitignore index 6dbfc40..34566dd 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,9 @@ dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties +### Local secrets ### +application-local.yaml + ### Project ### contacts.zip contacts.json diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index 65f8d6b..322f2c6 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -4,6 +4,7 @@ hono htmx shadcn + yamcodes \ No newline at end of file diff --git a/.run/Docker Compose Up.run.xml b/.run/Docker Compose Up.run.xml new file mode 100644 index 0000000..75885b8 --- /dev/null +++ b/.run/Docker Compose Up.run.xml @@ -0,0 +1,11 @@ + + + + diff --git a/.run/Spring Development.run.xml b/.run/Spring Development.run.xml index 212f448..bfc3866 100644 --- a/.run/Spring Development.run.xml +++ b/.run/Spring Development.run.xml @@ -1,10 +1,11 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 1ba173e..5cce395 100644 --- a/README.md +++ b/README.md @@ -21,53 +21,90 @@ This app follows the architecture from [Hypermedia Systems](https://hypermedia.s - Add, edit, and delete contact details - Server-rendered HTML with Thymeleaf - Partial page updates (no full reloads) -- H2 in-memory database with Spring Data JPA +- PostgreSQL + Spring Data JPA + Flyway migrations ## Development -### Quickstart +### Prerequisites + +- Java 25 +- Docker (for local Postgres) + +### Setup + +1. **Create `./config/application-local.yaml`** (gitignored — never committed, lives outside the source tree): + +```yaml +spring: + security: + user: + name: + password: + datasource: + url: jdbc:postgresql://localhost:5432/contacts + username: contacts + password: contacts + jpa: + hibernate: + ddl-auto: validate + sql: + init: + mode: always + thymeleaf: + cache: false + prefix: file:src/main/resources/templates/ + devtools: + livereload: + enabled: true + restart: + additional-paths: src/main/resources/templates/ + additional-exclude: "**/*.html" + web: + resources: + static-locations: + - file:src/main/resources/static/ + - classpath:/static/ + cache: + period: 0 + chain: + cache: false +``` -```bash -# Start dev server -./mvnw spring-boot:run +2. **Start Postgres:** -# Open in browser -open http://localhost:8080 +```bash +./scripts/start-db.sh ``` -### VS Code +3. **Run the app:** -1. **Install extensions** - open the Extensions panel, search `@recommended`, and install all workspace recommendations (Java Extension Pack, Spring Boot Extension Pack, Lombok, htmx attributes) -2. **Java 25 SDK** - VS Code will prompt to download a JDK if none is found; select Java 25 (if you have multiple JDKs, point `java.jdt.ls.java.home` in user settings to your Java 25 installation) -3. **Run** - use the **Run** task via `Ctrl+Shift+P → Tasks: Run Task → Run`, or press `Ctrl+Shift+P → Spring Boot Dashboard: Run` from the Spring Boot extension - -**Tips:** - -- Spring Boot DevTools (already included) enables hot reload - save a file and changes apply automatically -- Thymeleaf templates hot-reload without a restart -- H2 console is available at `http://localhost:8080/h2-console` +```bash +./mvnw spring-boot:run -Dspring-boot.run.profiles=local +``` ### IntelliJ IDEA 1. **Open** - **File → Open**, select `pom.xml`, choose **"Open as Project"** 2. **Java 25 SDK** - **File → Project Structure → SDKs**, add Java 25 if missing (IntelliJ can download it) 3. **Lombok** - install the [Lombok plugin](https://plugins.jetbrains.com/plugin/6317-lombok) via **Settings → Plugins**, then enable annotation processing under **Settings → Build, Execution, Deployment → Compiler → Annotation Processors** -4. **Run** - use the included **Contacts** run configuration (`.run/Contacts.run.xml`) from the toolbar, or run `spring-boot:run` from the Maven tool window +4. **Create `./config/application-local.yaml`** - see [Setup](#setup) above +5. **Run** - use the included **Development** compound run configuration from the toolbar — it starts Docker, waits for Postgres, then launches the app **Tips:** - Spring Boot DevTools (already included) enables hot reload - recompile with `Ctrl+F9` without restarting - Enable **"Build project automatically"** (**Settings → Build, Execution, Deployment → Compiler**) and **"Allow auto-make to start even if developed application is currently running"** (**Settings → Advanced Settings**) for seamless reloads on save - Thymeleaf templates hot-reload without a restart -- H2 console is available at `http://localhost:8080/h2-console` ### Scripts -| Command | Description | -| ------------------------ | ---------------- | -| `./mvnw spring-boot:run` | Start dev server | -| `./mvnw test` | Run tests | -| `./mvnw package` | Build jar | +| Command | Description | +| --------------------------------------------------------- | -------------------- | +| `./scripts/start-db.sh` | Start local Postgres | +| `docker compose down` | Stop local Postgres | +| `./mvnw spring-boot:run -Dspring-boot.run.profiles=local` | Start dev server | +| `./mvnw test` | Run tests | +| `./mvnw package` | Build jar | ## Project structure @@ -81,6 +118,9 @@ src/ │ │ ├── ContactRepository.java # Spring Data JPA (search, pagination) │ │ └── ContactService.java # Validation, slug generation, business logic │ └── resources/ +│ ├── db/migration/ +│ │ ├── V1__init.sql # Initial schema (Flyway) +│ │ └── V2__seed.sql # Seed data (Flyway) │ ├── templates/ │ │ ├── layout.html # Base layout (Thymeleaf fragment) │ │ ├── contacts/ @@ -97,13 +137,18 @@ src/ │ │ ├── styles.css │ │ └── img/ │ │ └── spinning-circles.svg # Loading spinner -│ └── application.yaml -└── test/ - └── java/codes/yam/contacts/ - ├── controller/ - │ └── ContactControllerTest.java - └── service/ - └── ContactServiceTest.java +│ └── application.yaml # Production config (env var placeholders) +config/ +└── application-local.yaml # Local overrides — gitignored, never committed +├── test/ +│ └── java/codes/yam/contacts/ +│ ├── controller/ +│ │ └── ContactControllerTest.java +│ └── service/ +│ └── ContactServiceTest.java +scripts/ +└── start-db.sh # docker compose up + wait for Postgres readiness +docker-compose.yml # Local Postgres service ``` ### Routes @@ -130,7 +175,8 @@ src/ | Framework | [Spring Boot](https://spring.io/projects/spring-boot) 4.0.3 | | Templating | [Thymeleaf](https://www.thymeleaf.org) | | Hypermedia | [htmx](https://htmx.org) + [htmx-spring-boot-thymeleaf](https://github.com/wimdeblauwe/htmx-spring-boot) | -| Database | H2 (in-memory) + Spring Data JPA | +| Database | PostgreSQL + Spring Data JPA | +| Migrations | [Flyway](https://flywaydb.org) | | Build | Maven | ## Branches diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f35bc2f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + db: + image: postgres:17 + environment: + POSTGRES_DB: contacts + POSTGRES_USER: contacts + POSTGRES_PASSWORD: contacts + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U contacts -d contacts"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + postgres_data: diff --git a/pom.xml b/pom.xml index 100e0ba..f109dbf 100644 --- a/pom.xml +++ b/pom.xml @@ -32,11 +32,19 @@ org.springframework.boot - spring-boot-h2console + spring-boot-starter-data-jpa org.springframework.boot - spring-boot-starter-data-jpa + spring-boot-starter-flyway + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql org.springframework.boot @@ -72,10 +80,15 @@ runtime true + + org.postgresql + postgresql + runtime + com.h2database h2 - runtime + test org.projectlombok diff --git a/scripts/start-db.sh b/scripts/start-db.sh new file mode 100755 index 0000000..1b4434e --- /dev/null +++ b/scripts/start-db.sh @@ -0,0 +1,12 @@ +#!/bin/sh +MAX_RETRIES=30 +docker compose up -d +i=0 +until docker compose exec -T db pg_isready -q; do + i=$((i + 1)) + if [ "$i" -ge "$MAX_RETRIES" ]; then + echo "Postgres did not become ready after $MAX_RETRIES seconds" >&2 + exit 1 + fi + sleep 1 +done diff --git a/src/main/java/codes/yam/contacts/SecurityConfig.java b/src/main/java/codes/yam/contacts/SecurityConfig.java index 3f9bf65..5c2451c 100644 --- a/src/main/java/codes/yam/contacts/SecurityConfig.java +++ b/src/main/java/codes/yam/contacts/SecurityConfig.java @@ -5,7 +5,6 @@ import org.springframework.security.config.Customizer; 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.HeadersConfigurer; import org.springframework.security.web.SecurityFilterChain; @Configuration @@ -13,12 +12,9 @@ public class SecurityConfig { @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http) { - return http.authorizeHttpRequests( - auth -> auth.requestMatchers("/h2-console/**").permitAll().anyRequest().authenticated()) + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .formLogin(Customizer.withDefaults()) - .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) - .csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")) .build(); } } diff --git a/src/main/resources/application-dev.yaml b/src/main/resources/application-dev.yaml deleted file mode 100644 index aac9eca..0000000 --- a/src/main/resources/application-dev.yaml +++ /dev/null @@ -1,26 +0,0 @@ -spring: - security: - user: - name: admin - password: admin - h2: - console: - enabled: true - thymeleaf: - cache: false - prefix: file:src/main/resources/templates/ - devtools: - livereload: - enabled: true - restart: - additional-paths: src/main/resources/templates/ - additional-exclude: "**/*.html" - web: - resources: - static-locations: - - file:src/main/resources/static/ - - classpath:/static/ - cache: - period: 0 - chain: - cache: false diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 1f12822..f0640ff 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -4,9 +4,13 @@ spring: thymeleaf: cache: true jpa: - defer-datasource-initialization: true + open-in-view: false + hibernate: + ddl-auto: validate datasource: - url: jdbc:h2:mem:contacts;DB_CLOSE_DELAY=-1 + url: ${DATABASE_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} security: user: name: ${APP_USERNAME} diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..ab85c24 --- /dev/null +++ b/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,8 @@ +CREATE TABLE contact ( + id UUID PRIMARY KEY, + slug VARCHAR(255), + first VARCHAR(255), + last VARCHAR(255), + email VARCHAR(255), + phone VARCHAR(255) +); diff --git a/src/main/resources/db/migration/V2__seed.sql b/src/main/resources/db/migration/V2__seed.sql new file mode 100644 index 0000000..cfb369a --- /dev/null +++ b/src/main/resources/db/migration/V2__seed.sql @@ -0,0 +1,31 @@ +INSERT INTO contact (id, slug, first, last, email, phone) VALUES +('550e8400-e29b-41d4-a716-446655440000', 'john-doe', 'John', 'Doe', 'john@example.com', '555-1234'), +('550e8400-e29b-41d4-a716-446655440001', 'jane-doe', 'Jane', 'Doe', 'jane@example.com', '555-5678'), +('550e8400-e29b-41d4-a716-446655440002', 'alice-smith', 'Alice', 'Smith', 'alice.smith@example.com', '555-1001'), +('550e8400-e29b-41d4-a716-446655440003', 'bob-johnson', 'Bob', 'Johnson', 'bob.johnson@example.com', '555-1002'), +('550e8400-e29b-41d4-a716-446655440004', 'carol-williams', 'Carol', 'Williams', 'carol.williams@example.com', '555-1003'), +('550e8400-e29b-41d4-a716-446655440005', 'david-brown', 'David', 'Brown', 'david.brown@example.com', '555-1004'), +('550e8400-e29b-41d4-a716-446655440006', 'eve-jones', 'Eve', 'Jones', 'eve.jones@example.com', '555-1005'), +('550e8400-e29b-41d4-a716-446655440007', 'frank-garcia', 'Frank', 'Garcia', 'frank.garcia@example.com', '555-1006'), +('550e8400-e29b-41d4-a716-446655440008', 'grace-miller', 'Grace', 'Miller', 'grace.miller@example.com', '555-1007'), +('550e8400-e29b-41d4-a716-446655440009', 'henry-davis', 'Henry', 'Davis', 'henry.davis@example.com', '555-1008'), +('550e8400-e29b-41d4-a716-44665544000a', 'iris-martinez', 'Iris', 'Martinez', 'iris.martinez@example.com', '555-1009'), +('550e8400-e29b-41d4-a716-44665544000b', 'jack-wilson', 'Jack', 'Wilson', 'jack.wilson@example.com', '555-1010'), +('550e8400-e29b-41d4-a716-44665544000c', 'karen-anderson', 'Karen', 'Anderson', 'karen.anderson@example.com', '555-1011'), +('550e8400-e29b-41d4-a716-44665544000d', 'leo-thomas', 'Leo', 'Thomas', 'leo.thomas@example.com', '555-1012'), +('550e8400-e29b-41d4-a716-44665544000e', 'mia-taylor', 'Mia', 'Taylor', 'mia.taylor@example.com', '555-1013'), +('550e8400-e29b-41d4-a716-44665544000f', 'noah-hernandez', 'Noah', 'Hernandez', 'noah.hernandez@example.com', '555-1014'), +('550e8400-e29b-41d4-a716-446655440010', 'olivia-moore', 'Olivia', 'Moore', 'olivia.moore@example.com', '555-1015'), +('550e8400-e29b-41d4-a716-446655440011', 'paul-jackson', 'Paul', 'Jackson', 'paul.jackson@example.com', '555-1016'), +('550e8400-e29b-41d4-a716-446655440012', 'quinn-martin', 'Quinn', 'Martin', 'quinn.martin@example.com', '555-1017'), +('550e8400-e29b-41d4-a716-446655440013', 'rachel-lee', 'Rachel', 'Lee', 'rachel.lee@example.com', '555-1018'), +('550e8400-e29b-41d4-a716-446655440014', 'sam-perez', 'Sam', 'Perez', 'sam.perez@example.com', '555-1019'), +('550e8400-e29b-41d4-a716-446655440015', 'tina-white', 'Tina', 'White', 'tina.white@example.com', '555-1020'), +('550e8400-e29b-41d4-a716-446655440016', 'ursula-harris', 'Ursula', 'Harris', 'ursula.harris@example.com', '555-1021'), +('550e8400-e29b-41d4-a716-446655440017', 'victor-clark', 'Victor', 'Clark', 'victor.clark@example.com', '555-1022'), +('550e8400-e29b-41d4-a716-446655440018', 'wendy-lewis', 'Wendy', 'Lewis', 'wendy.lewis@example.com', '555-1023'), +('550e8400-e29b-41d4-a716-446655440019', 'xavier-robinson', 'Xavier', 'Robinson', 'xavier.robinson@example.com', '555-1024'), +('550e8400-e29b-41d4-a716-44665544001a', 'yara-walker', 'Yara', 'Walker', 'yara.walker@example.com', '555-1025'), +('550e8400-e29b-41d4-a716-44665544001b', 'zane-hall', 'Zane', 'Hall', 'zane.hall@example.com', '555-1026'), +('550e8400-e29b-41d4-a716-44665544001c', 'amber-allen', 'Amber', 'Allen', 'amber.allen@example.com', '555-1027'), +('550e8400-e29b-41d4-a716-44665544001d', 'brian-young', 'Brian', 'Young', 'brian.young@example.com', '555-1028'); diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml new file mode 100644 index 0000000..35deefc --- /dev/null +++ b/src/test/resources/application.yaml @@ -0,0 +1,16 @@ +spring: + flyway: + enabled: false + datasource: + url: jdbc:h2:mem:contacts;DB_CLOSE_DELAY=-1 + jpa: + hibernate: + ddl-auto: create-drop + defer-datasource-initialization: true + sql: + init: + mode: always + security: + user: + name: admin + password: admin