Skip to content

feat: add easy-paging-postgres-demo (real PG via Docker + Testcontainers)#5

Merged
jlc488 merged 1 commit into
mainfrom
feat/easy-paging-postgres-demo
May 23, 2026
Merged

feat: add easy-paging-postgres-demo (real PG via Docker + Testcontainers)#5
jlc488 merged 1 commit into
mainfrom
feat/easy-paging-postgres-demo

Conversation

@jlc488
Copy link
Copy Markdown
Contributor

@jlc488 jlc488 commented May 23, 2026

Summary

Third demo in this repo. Proves `easy-paging-spring-boot-starter` (0.4.0) works end-to-end against the database most teams ship with — PostgreSQL — and establishes the Docker pattern every future "external DB" demo in this repo will follow.

PageHelper handles the dialect; the controller / service / mapper layer is unchanged from the H2 demo. The interesting thing this PR adds is how you run and test against a real DB without forcing a local install.

The two-Docker-paths pattern

Deliberately separate, intentionally non-overlapping:

Path Used by What it spins up
`docker compose up -d db` A human doing `./gradlew bootRun` `docker-compose.yml` in the demo dir — long-lived PG on published port 5432, named volume
`@Testcontainers + @ServiceConnection` `./gradlew test` (locally and in CI) Ephemeral `postgres:16-alpine` on a random port, torn down per test class

The two paths don't share state — the compose port and the Testcontainers random port don't collide, and `@ServiceConnection` overrides `spring.datasource.url` at test bootstrap so `application.yml`'s defaults never reach the test JVM. CI on Ubuntu runners works unchanged because the runner already has Docker; no "install Postgres first" step anywhere.

This is the template the upcoming `easy-paging-reactive-demo` (and any later external-DB demo) will copy.

Demo content

  • `Product` entity: `(id, name, price, category, createdAt)` — classic catalog-listing use case for paginated REST.
  • Seed: 500 rows via `generate_series(1, 500)`, distributed deterministically across 5 categories (100 each) so the integration test can assert exact totals.
  • PG-native schema: `BIGSERIAL`, `NUMERIC(10,2)`, composite index — features that wouldn't run on H2 without compatibility shims.
  • `ProductController` adds a `?category=` filter on top of the pagination contract, showing the starter coexists with arbitrary query parameters.

Integration test (`ProductControllerIT`)

Covers:

  • Envelope metadata (totals, pages, first/last flags) on the unfiltered listing
  • `?category=` reducing `totalElements` from 500 to 100
  • `?sort=` SQL-injection attempts rejected with HTTP 400 before reaching the DB, and the table is verified intact afterwards
  • `@AutoPaginate(maxSize = 100)` clamping a `?size=9999` request

Verification

  • Compile: green locally.
  • Local test run: blocked on my laptop by a stale `~/.testcontainers.properties` (`docker.host = tcp://127.0.0.1:7514` from an old Docker setup) — I didn't modify the global config to work around it. CI is the meaningful verification here; the Ubuntu runner uses the unix socket with no overrides.

CI expectation

The `detect` job from #1/#3 should identify `easy-paging-postgres-demo/` as the only changed demo. The `build` matrix job will then run `./gradlew build`, which:

  1. Pulls `postgres:16-alpine` (~80MB, first time only — cached after).
  2. Starts a Testcontainers Postgres on a random port.
  3. `@ServiceConnection` rewires the datasource.
  4. Spring Boot applies `schema.sql` + `data.sql` against the real PG.
  5. The four `ProductControllerIT` assertions run.

Expected to take 1-2 minutes longer than the H2 demos due to the image pull on the first run.

Production-grade caveat (in the README)

`spring.sql.init` with `DROP+CREATE` is fine for a learning demo where the goal is "always boot into a known state with no setup". The README explicitly tells readers to use Flyway or Liquibase in real apps.

What's next (not in this PR)

Per the agreed roadmap, the last remaining easy-paging demo is:

  • `easy-paging-reactive-demo` — `easy-paging-spring-boot-starter-reactive` companion artifact (R2DBC + WebFlux), reusing the Docker Compose + Testcontainers pattern this PR establishes.

Test plan

  • CI `detect` job identifies only `easy-paging-postgres-demo` as changed
  • CI `build` job pulls postgres:16-alpine and runs all 4 `ProductControllerIT` tests green
  • After merge, README on `main` lists three demos with working links

Third demo. Proves easy-paging-spring-boot-starter (0.4.0) works
end-to-end against the database most teams actually ship with —
PostgreSQL — not just H2. PageHelper handles the dialect; the
controller code is unchanged from the H2 demo.

Establishes the Docker pattern the repo will use for every "external
DB" demo from here on. Two Docker paths, deliberately separate:

  1. docker-compose.yml in this directory — for a human doing
     `docker compose up -d db && ./gradlew bootRun`. No local Postgres
     install required on the developer's laptop.
  2. Testcontainers + @Serviceconnection in ProductControllerIT — for
     `./gradlew test`, locally or in CI. Spins up an ephemeral
     postgres:16-alpine on a random port, tears it down after the test
     class. CI on Ubuntu runners works unchanged because the runner
     already has Docker; no "install Postgres first" step.

The two paths don't share state: the compose file's published port and
the Testcontainers random port don't collide, and @Serviceconnection
overrides spring.datasource.url at test bootstrap so application.yml's
defaults never reach the test JVM.

Demo content:
- Product entity (id, name, price, category, createdAt) — classic
  catalog-listing use case for paginated REST.
- 500 rows seeded via generate_series(1, 500) and distributed
  deterministically across 5 categories (100 each) so the
  integration test can assert exact totals.
- schema.sql uses BIGSERIAL, NUMERIC(10,2), and a composite index —
  PG-native features that wouldn't run on H2 without compatibility
  shims, to make the point that the starter is at home on real PG.
- ProductController adds a ?category=... filter on top of the
  pagination contract, showing the starter coexists with arbitrary
  query parameters and Pageable arguments.

The integration test (ProductControllerIT) covers:
- envelope metadata (totals, pages, first/last flags) on the
  unfiltered listing,
- ?category= filter reducing totalElements from 500 to 100,
- ?sort= injection attempts rejected with HTTP 400 before reaching
  the DB (and verifies the table is still there afterwards),
- @AutoPaginate(maxSize = 100) clamping a ?size=9999 request.

README notes the production-grade caveat that spring.sql.init is for
demos only — real apps should use Flyway or Liquibase.

Top-level README updated to list this demo alongside the H2 ones.

Local verification note: my own laptop has a stale
~/.testcontainers.properties from a previous Docker setup pointing at
a non-listening port, so the local `./gradlew test` couldn't reach
Docker. The code compiles cleanly and CI on a fresh Ubuntu runner
(unix socket, no overrides) is the meaningful verification — that
will run on this PR.
@jlc488 jlc488 merged commit 263d65e into main May 23, 2026
3 checks passed
@jlc488 jlc488 deleted the feat/easy-paging-postgres-demo branch May 23, 2026 05:39
jlc488 added a commit that referenced this pull request May 23, 2026
… PATCH fixes)

api-log v3.0.1 published to Maven Central
(devslab-kr/api-log#4 + #5 merged, tag pushed, release workflow succeeded
at 16:39Z, all four artifacts indexed). 3.0.1 fixes two bugs in
RestApiClientUtil / ReactiveApiClientUtil that the previous PR #62 CI
runs surfaced:

  1. Content-Type missing on POST/PUT/PATCH body -> upstream returned
     415/500 (the postBodyIsPreservedInPayloadColumn failures across all
     three demos).
  2. PATCH method unsupported because SimpleClientHttpRequestFactory
     wraps java.net.HttpURLConnection (which rejects PATCH).

Bump scope: one line per demo build.gradle.kts (well, two — core +
backend), three demos = 6 lines. No source changes needed — the bugs
were entirely in the starter, the demo code was already calling the
right APIs.

Expecting all 15 IT tests (5 per demo) to pass on this run now that the
starter is fixed.
jlc488 added a commit that referenced this pull request May 25, 2026
…stcontainers IT (#62)

* feat: add api-log demo set (jpa + mybatis + r2dbc) with PostgreSQL Testcontainers IT

Covers all three persistence backends of the api-log starter
(kr.devslab:api-log-{jpa,mybatis,r2dbc}:3.0.0) so a reader can pick
the demo that matches their stack and `./gradlew bootRun` immediately.

## Demos added

  api-log-jpa-demo/       Spring MVC + JPA + Postgres        (blocking)
  api-log-mybatis-demo/   Spring MVC + MyBatis + Postgres    (blocking)
  api-log-r2dbc-demo/     WebFlux + R2DBC + Postgres         (reactive)

## Common design — self-loopback

Each demo exposes three controllers in the same app:

  /upstream/widgets/{id}         the "service being called" — returns a
                                 fake Widget, with id=999 forcing a 5xx
                                 to exercise the error path
  /client/widgets/{id}           calls upstream via RestApiClientUtil
                                 (or ReactiveApiClientUtil for r2dbc) so
                                 the call gets logged into api_log
  /client/widgets/with-request-id/{id}
                                 same shape but passes an explicit
                                 requestId via the core
                                 send(HttpMethod, ApiRequest) overload —
                                 demonstrates the retry-correlation API
  /api-log/recent                reads the api_log table (top 20 by
                                 timestamp DESC)
  /api-log/by-request/{rid}      reads all rows for one requestId
                                 (lifecycle: INITIATED → SUCCESS / ERROR)
  /api-log/by-event/{type}       reads all rows for one event type

Self-loopback means no external dep — `docker compose up -d db &&
./gradlew bootRun && curl localhost:8080/client/widgets/123` is the full
demo. Reader can immediately see INITIATED + SUCCESS pairs in
/api-log/recent after the async event listener flushes.

## Tests — Testcontainers + @Serviceconnection

Each demo ships an integration test (ApiLogLifecycleIT) that spins up
postgres:16-alpine via Testcontainers, makes real HTTP self-loopback
calls (RestClient.create / TestRestTemplate / WebTestClient depending
on the demo's stack), and asserts on the api_log rows that the async
listener writes. Awaitility polls past the listener's async window.

Five tests per demo:
  1. happy GET path → INITIATED + SUCCESS in api_log
  2. error path (id=999) → INITIATED + ERROR
  3. POST body preserved in api_log.payload (JSONB)
  4. explicit requestId correlation via /with-request-id/{id}
  5. schema initialized on boot (table exists, query succeeds)

15 IT tests total across the three demos. CI builds them automatically
(the workflow's detect step picks up new demos by the presence of
build.gradle.kts — no workflow edits needed).

## Backend-specific notes

JPA:
  - spring-boot-starter-data-jpa + api-log-jpa
  - Reader uses ApiLogRepository (the starter publishes it as a
    Spring Data repository — extends JpaRepository<ApiLogEntity, Long>)
  - spring.jpa.hibernate.ddl-auto=none — api-log starter's
    ApiLogJpaSchemaInitializer creates the api_log table itself
    (api.log.schema.management=BUILTIN)

MyBatis:
  - mybatis-spring-boot-starter:4.0.1 + api-log-mybatis
  - Reader uses the bundled ApiLogMapper for findByRequestId,
    plus a custom ApiLogQueryMapper (xml) for findRecent / findByEvent
    since the starter's mapper doesn't expose those
  - @MapperScan scoped to the demo package only (the starter's mapper
    is registered by its own auto-config — scanning it twice conflicts)

R2DBC:
  - spring-boot-starter-webflux + spring-boot-starter-data-r2dbc +
    api-log-r2dbc
  - Reader uses DatabaseClient (not a Spring Data R2DBC repository —
    the api-log r2dbc backend doesn't ship one, keeping its dep
    footprint minimal). Cast JSONB columns to ::text so they bind
    cleanly to String fields on a public ApiLogView record.
  - Schema initializer is opt-in via api.log.r2dbc.schema.enabled=true
    (separate property from the JDBC backends because reactive init
    runs on ConnectionFactory not DataSource — per ApiLogProperties
    javadoc)
  - Both r2dbc-postgresql AND jdbc postgresql drivers in the build —
    the latter is what Testcontainers' @Serviceconnection prefers for
    rewiring the connection.

## Root README updates

Added an "### api-log" section after the ssrf-guard section in both
README.md and README.ko.md, with the three new demos linked from a
single table. Matches the existing table convention (Maven Central
shields.io badge with verbose label) — a follow-up that strips the
labels to the default "Maven Central" form (per devslab-examples#59)
would touch all rows at once.

## Sibling work in flight

devslab-examples#61 adds two more ssrf-guard demos
(ssrf-guard-httpclient5-demo + ssrf-guard-native-image-demo) on a
parallel branch. The two PRs are independent — they touch different
directories. README.md will conflict on the section ordering after both
land; whichever merges second rebases.

* fix(api-log demos): mark gradlew executable in git index

CI failed with `./gradlew: Permission denied` (exit 126) on all three
new demos. The agents created the wrappers via `cp -r` on Windows,
where the filesystem doesn't track executable bits, so git stored them
as 0644. Linux CI runners then refused to execute them.

`git update-index --chmod=+x` flips the index mode to 100755 without
changing file content. Same fix as #61 used (commit 2dbf4a4).

* fix(api-log demos): downgrade to Spring Boot 3.5.6 baseline

CI failed with BeanTypeDeductionException -> ClassNotFoundException
during Spring context load on all three demo ITs. Root cause: api-log
3.0.0 is built against Spring Boot 3.5.6 per the Spring-major-aligned
versioning policy (devslab-kr/.github/.github/VERSIONING.md — lib
major = SB major, so api-log 3.x = SB3 line). My demos were on SB4.0.6
and the runtime classpath had api-log's compiled bytecode referencing
SB3.5 classes that don't exist in SB4 — gradle resolution upgraded the
spring-boot-* artifacts to 4.0.6 but didn't paper over the API drift.

Changes per demo (build.gradle.kts):

  Plugin                4.0.6 -> 3.5.6
  Test starters         removed SB4-only -webmvc-test / -webflux-test /
                        -resttestclient modules (MockMvc / WebTestClient /
                        TestRestTemplate all ship in plain
                        spring-boot-starter-test on SB3)
  Testcontainers BOM    removed (relying on Spring Boot 3.5.6's managed
                        Testcontainers versions — same approach as
                        easy-paging-postgres-demo + easy-paging-reactive-demo)
  Testcontainers names  testcontainers-postgresql -> postgresql,
                        testcontainers-junit-jupiter -> junit-jupiter
                        (Testcontainers 1.x naming convention, what SB3
                        BOM pins)
  MyBatis starter       mybatis-spring-boot-starter:4.0.1 -> :3.0.4
                        (SB3-compatible line; matches what PageHelper +
                        Spring Boot 3 BOM expects)
  R2DBC extras          added testcontainers:r2dbc (the R2DBC
                        @Serviceconnection bridge for SB3 — matches
                        easy-paging-reactive-demo)
  Unchanged             api-log 3.0.0 deps, awaitility 4.2.2, R2DBC +
                        JDBC postgres drivers, dependency-management
                        1.1.7, JDK 21 toolchain, junit-platform-launcher

Changes per IT (imports):

  PostgreSQLContainer   org.testcontainers.postgresql.PostgreSQLContainer
                        -> org.testcontainers.containers.PostgreSQLContainer
                        (Testcontainers 1.x class location)
  Container generic     raw PostgreSQLContainer -> PostgreSQLContainer<?>
                        field type + new PostgreSQLContainer<>("...")
                        (1.x self-typed generic, diamond form)
  @AutoConfigureWebTestClient (r2dbc only)
                        org.springframework.boot.webtestclient.autoconfigure
                        -> org.springframework.boot.test.autoconfigure.web.reactive
  TestRestTemplate (mybatis only)
                        org.springframework.boot.resttestclient
                        -> org.springframework.boot.test.web.client
  @LocalServerPort     no change (lives at
                        org.springframework.boot.test.web.server in both
                        SB3.5.6 and SB4 — spec mid-flight said otherwise,
                        agents caught it via compile error)

Test logic itself unchanged (5 tests × 3 demos = 15 IT tests). Main-
source files (controllers, Widget record, ApiLogReader/View, MyBatis
mapper xml, application.yml, docker-compose.yml, READMEs) all
source-compatible between SB3 and SB4 — only build pin + a handful of
test imports needed to move.

Compile verified clean on all 3 demos. Awaiting CI for the runtime path.

Follow-up: once api-log ships a 4.x line (the VERSIONING.md policy
schedules it for whenever the project decides to add SB4 support), add
api-log-{jpa,mybatis,r2dbc}-sb4-demo siblings — same pattern as the
easy-paging dual-line setup.

* fix(api-log demos): self-loopback URL uses runtime local.server.port

The 3 CI test failures (happyGet / postBody / explicitRequestId — all
the success-path tests) were a real bug, not a flake: ClientController
read `${api-log-demo.upstream-base-url}` which expanded to
`http://localhost:8080` at @value injection time. The integration test
boots on RANDOM_PORT — e.g. 54321 — so:

  Test → http://localhost:54321/client/widgets/123     OK
  ClientController → http://localhost:8080/upstream/widgets/123  CONN REFUSED → 500

errorPath and schemaInit passed for the wrong reason — errorPath asserts
an ERROR row shows up regardless of cause (connection-refused counts),
and schemaInit never touches HTTP.

Fix: don't hardcode the port in config. ClientController now injects
Environment and builds the upstream URL at request time from
`local.server.port` (which Spring Boot sets for BOTH bootRun and
RANDOM_PORT after the embedded server binds). One code path, one
behaviour, no test-specific override.

Removed `api-log-demo.upstream-base-url` from all 3 application.yml
since nothing reads it anymore; comment now points users at
ClientController for the "swap upstream URL for production" instruction.

All three demos compile clean. The bug existed in the original SB4
version too — only surfaced after the SB3 downgrade got past the
ContextLoader exception and tests could actually run.

* chore(api-log demos): bump 3.0.0 -> 3.0.1 (HTTP client Content-Type + PATCH fixes)

api-log v3.0.1 published to Maven Central
(devslab-kr/api-log#4 + #5 merged, tag pushed, release workflow succeeded
at 16:39Z, all four artifacts indexed). 3.0.1 fixes two bugs in
RestApiClientUtil / ReactiveApiClientUtil that the previous PR #62 CI
runs surfaced:

  1. Content-Type missing on POST/PUT/PATCH body -> upstream returned
     415/500 (the postBodyIsPreservedInPayloadColumn failures across all
     three demos).
  2. PATCH method unsupported because SimpleClientHttpRequestFactory
     wraps java.net.HttpURLConnection (which rejects PATCH).

Bump scope: one line per demo build.gradle.kts (well, two — core +
backend), three demos = 6 lines. No source changes needed — the bugs
were entirely in the starter, the demo code was already calling the
right APIs.

Expecting all 15 IT tests (5 per demo) to pass on this run now that the
starter is fixed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant