diff --git a/google-cloud-sql/docs/cloud-sql-auth-proxy.md b/google-cloud-sql/docs/cloud-sql-auth-proxy.md index 900717b..e3c6dee 100644 --- a/google-cloud-sql/docs/cloud-sql-auth-proxy.md +++ b/google-cloud-sql/docs/cloud-sql-auth-proxy.md @@ -1,11 +1,24 @@ # Cloud SQL Auth Proxy -### Set instance connection name +### Set environment variables ```bash +export CREDENTIALS_PATH="/Users/admin/.config/gcloud/sa-private-key.json" +export GOOGLE_APPLICATION_CREDENTIALS="$CREDENTIALS_PATH" +export TF_VAR_project_id=$(gcloud config get-value project) +export TF_VAR_region="us-central1" +export TF_VAR_authorized_cidr="$(curl -4 -s ifconfig.me)/32" +export TF_VAR_db_user=username +export TF_VAR_db_password=password +export TF_VAR_db_name=jm_demo_db export INSTANCE_CONNECTION_NAME="lofty-root-378503:us-central1:jm-pg-demo" +export PGPASSWORD="$TF_VAR_db_password" ``` +### Provision DB infrastructure + +Use Terraform or `gcloud` to create the Cloud SQL Postgres instance. + ### Run the CloudSQL auth proxy Remove any old proxy container: @@ -28,8 +41,6 @@ PROXY_PID=$! #### Docker ```bash - -export CREDENTIALS_PATH="/Users/admin/.config/gcloud/sa-private-key.json" docker run -d --name csql-proxy -p 127.0.0.1:5400:5432 \ -v "$GOOGLE_APPLICATION_CREDENTIALS:/creds/key.json:ro" \ gcr.io/cloud-sql-connectors/cloud-sql-proxy:2 \ @@ -89,12 +100,6 @@ for p in {5400..5450}; do (lsof -iTCP:$p -sTCP:LISTEN >/dev/null) || { echo "Fir ### Test connection -```bash -export TF_VAR_db_user=username -export TF_VAR_db_password=password -export TF_VAR_db_name=jm_demo_db -``` - Have the proxy running and healthy to `127.0.0.1:5432`. Make sure nothing else is using port `5432`. If it is, pick another port (e.g., 5433) and update your `psql` command. @@ -110,7 +115,7 @@ PGPASSWORD="$TF_VAR_db_password" psql \ ### If the test is successful -Assuming the file `/src/test/resources/init.sql` exists: +Create the table from file contents. For example, assuming `/src/test/resources/init.sql` exists: ```bash PGPASSWORD="$TF_VAR_db_password" psql \ @@ -205,6 +210,10 @@ DROP TABLE widgets; ### Exit `psql` +```psql +\q +``` + ```psql exit ``` diff --git a/google-cloud-sql/pom.xml b/google-cloud-sql/pom.xml index 33e8ded..ad68e25 100644 --- a/google-cloud-sql/pom.xml +++ b/google-cloud-sql/pom.xml @@ -60,6 +60,14 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-jdbc + org.springframework.cloud @@ -78,6 +86,18 @@ true + + org.postgresql + postgresql + runtime + + + + + org.hibernate.orm + hibernate-core + + org.projectlombok lombok @@ -91,6 +111,11 @@ true + + com.h2database + h2 + test + org.springframework.boot spring-boot-starter-test @@ -110,6 +135,12 @@ + + org.postgresql + postgresql + 42.7.7 + runtime + com.google.cloud spring-cloud-gcp-dependencies diff --git a/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/controller/CloudSqlController.java b/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/controller/CloudSqlController.java deleted file mode 100644 index 53facd6..0000000 --- a/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/controller/CloudSqlController.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.squidmin.java.spring.maven.cloudsql.controller; - -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/cloud-sql") -public class CloudSqlController { - - public CloudSqlController() {} - - @GetMapping( - value = "/insert-rows", - consumes = MediaType.APPLICATION_JSON_VALUE, - produces = MediaType.APPLICATION_JSON_VALUE - ) - public ResponseEntity query() { - return ResponseEntity.ok("Placeholder response from Cloud SQL Controller"); - } - -} diff --git a/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/controller/WidgetController.java b/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/controller/WidgetController.java new file mode 100644 index 0000000..375054a --- /dev/null +++ b/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/controller/WidgetController.java @@ -0,0 +1,54 @@ +package org.squidmin.java.spring.maven.cloudsql.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.squidmin.java.spring.maven.cloudsql.service.WidgetService; +import org.squidmin.java.spring.maven.cloudsql.domain.Widget; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/cloud-sql/api/widgets") +public class WidgetController { + + private final WidgetService widgetService; + + public WidgetController(WidgetService widgetService) { + this.widgetService = widgetService; + } + + @GetMapping + public List list(@RequestParam(name = "q", required = false) String q) { + return widgetService.searchByName(q); + } + + /** + * Get a widget by ID + * @param id the widget ID + * @return the widget if found, or 404 if not found + */ + @GetMapping("{id}") + public ResponseEntity get(@PathVariable UUID id) { + return widgetService.findById(id) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping( + value = "/insert", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity insert(@RequestBody Widget widget) { + // Generate ID server-side if not provided + if (widget.getId() == null) { + widget.setId(UUID.randomUUID()); + } + + Widget saved = widgetService.save(widget); + return ResponseEntity.ok(saved); + } + +} diff --git a/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/domain/Widget.java b/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/domain/Widget.java new file mode 100644 index 0000000..400873f --- /dev/null +++ b/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/domain/Widget.java @@ -0,0 +1,46 @@ +package org.squidmin.java.spring.maven.cloudsql.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Entity +@Table(name = "widgets") +public class Widget { + + @Id + @Column(name = "id", nullable = false, columnDefinition = "uuid") + private UUID id; + + @Column(name = "name", nullable = false) + private String name; + + // DB sets default now(); mark read-only so we don’t overwrite it + @Column(name = "created_at", columnDefinition = "timestamptz", updatable = false, insertable = false) + private Instant createdAt; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "meta", columnDefinition = "jsonb") + private Map meta; + + public Widget() {} + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Instant getCreatedAt() { return createdAt; } + + public Map getMeta() { return meta; } + public void setMeta(Map meta) { this.meta = meta; } + +} diff --git a/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/repository/WidgetRepository.java b/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/repository/WidgetRepository.java new file mode 100644 index 0000000..03d5d5f --- /dev/null +++ b/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/repository/WidgetRepository.java @@ -0,0 +1,11 @@ +package org.squidmin.java.spring.maven.cloudsql.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.squidmin.java.spring.maven.cloudsql.domain.Widget; + +import java.util.List; +import java.util.UUID; + +public interface WidgetRepository extends JpaRepository { + List findByNameContainingIgnoreCase(String q); +} diff --git a/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/service/WidgetService.java b/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/service/WidgetService.java new file mode 100644 index 0000000..1a5362e --- /dev/null +++ b/google-cloud-sql/src/main/java/org/squidmin/java/spring/maven/cloudsql/service/WidgetService.java @@ -0,0 +1,36 @@ +package org.squidmin.java.spring.maven.cloudsql.service; + +import org.springframework.stereotype.Service; +import org.squidmin.java.spring.maven.cloudsql.domain.Widget; +import org.squidmin.java.spring.maven.cloudsql.repository.WidgetRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +public class WidgetService { + + private final WidgetRepository repo; + + public WidgetService(WidgetRepository repo) { + this.repo = repo; + } + + public List findAll() { + return repo.findAll(); + } + + public Optional findById(UUID id) { + return repo.findById(id); + } + + public List searchByName(String q) { + return (q == null || q.isBlank()) ? repo.findAll() : repo.findByNameContainingIgnoreCase(q); + } + + public Widget save(Widget widget) { + return repo.save(widget); + } + +} diff --git a/google-cloud-sql/src/main/resources/application.yml b/google-cloud-sql/src/main/resources/application.yml index ac29fc0..65f61bf 100644 --- a/google-cloud-sql/src/main/resources/application.yml +++ b/google-cloud-sql/src/main/resources/application.yml @@ -1,3 +1,18 @@ spring: application: name: google-cloud-sql + datasource: + url: jdbc:postgresql://127.0.0.1:5400/jm_demo_db + username: username + password: ${DB_PASSWORD:password} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + + jpa: + open-in-view: false + hibernate: + ddl-auto: none + properties: + hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect diff --git a/google-cloud-sql/src/test/java/org/squidmin/java/spring/maven/cloudsql/actuator/ActuatorTest.java b/google-cloud-sql/src/test/java/org/squidmin/java/spring/maven/cloudsql/actuator/ActuatorTest.java new file mode 100644 index 0000000..b7e0f30 --- /dev/null +++ b/google-cloud-sql/src/test/java/org/squidmin/java/spring/maven/cloudsql/actuator/ActuatorTest.java @@ -0,0 +1,45 @@ +package org.squidmin.java.spring.maven.cloudsql.actuator; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "management.endpoints.web.exposure.include=health,info", + "management.endpoint.health.show-details=always", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.jpa.hibernate.ddl-auto=none" + } +) +public class ActuatorTest { + + @LocalServerPort + int port; + + @Autowired + TestRestTemplate rest; + + @Test + void health_isUp() { + ResponseEntity res = + rest.getForEntity("http://localhost:" + port + "/actuator/health", String.class); + assertThat(res.getStatusCode().is2xxSuccessful()).isTrue(); + assertThat(res.getBody()).contains("\"status\":\"UP\""); + } + + @Test + void info_isReachable() { + ResponseEntity res = + rest.getForEntity("http://localhost:" + port + "/actuator/info", String.class); + assertThat(res.getStatusCode().is2xxSuccessful()).isTrue(); + } + +} diff --git a/google-cloud-sql/src/test/resources/application-test.yml b/google-cloud-sql/src/test/resources/application-test.yml index ac29fc0..65f61bf 100644 --- a/google-cloud-sql/src/test/resources/application-test.yml +++ b/google-cloud-sql/src/test/resources/application-test.yml @@ -1,3 +1,18 @@ spring: application: name: google-cloud-sql + datasource: + url: jdbc:postgresql://127.0.0.1:5400/jm_demo_db + username: username + password: ${DB_PASSWORD:password} + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + + jpa: + open-in-view: false + hibernate: + ddl-auto: none + properties: + hibernate.dialect: org.hibernate.dialect.PostgreSQLDialect diff --git a/google-cloud-sql/src/test/resources/init.sql b/google-cloud-sql/src/test/resources/init.sql index 664bd59..c9927bb 100644 --- a/google-cloud-sql/src/test/resources/init.sql +++ b/google-cloud-sql/src/test/resources/init.sql @@ -1,6 +1,7 @@ -CREATE TABLE IF NOT EXISTS widgets ( - id UUID PRIMARY KEY, - name TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - meta JSONB +CREATE TABLE IF NOT EXISTS widgets +( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + meta JSONB ); diff --git a/java21-spring3-maven-reference/cloud-sql-api-widgets-{id}.bru b/java21-spring3-maven-reference/cloud-sql-api-widgets-{id}.bru new file mode 100644 index 0000000..3cfe14d --- /dev/null +++ b/java21-spring3-maven-reference/cloud-sql-api-widgets-{id}.bru @@ -0,0 +1,15 @@ +meta { + name: /cloud-sql/api/widgets/{id} + type: http + seq: 15 +} + +get { + url: http://localhost:8080/cloud-sql/api/widgets/ca0fbbdc-4ccd-4a98-9b13-08565e9db5a0 + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/java21-spring3-maven-reference/cloud-sql-api-widgets.bru b/java21-spring3-maven-reference/cloud-sql-api-widgets.bru new file mode 100644 index 0000000..7cfdeb4 --- /dev/null +++ b/java21-spring3-maven-reference/cloud-sql-api-widgets.bru @@ -0,0 +1,19 @@ +meta { + name: /cloud-sql/api/widgets + type: http + seq: 13 +} + +get { + url: http://localhost:8080/cloud-sql/api/widgets?q=Test Widget + body: none + auth: inherit +} + +params:query { + q: Test Widget +} + +settings { + encodeUrl: true +}