diff --git a/README.md b/README.md index f98971e9..e43b9732 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,8 @@ Running various samples requires access to the Kyma environment. There are also | Name | Description | References | | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| [Deploy a Go PostgreSQL API Endpoint in SAP BTP, Kyma Runtime](./api-postgresql-go/README.md) | This sample provides a Golang API endpoint for communication with an MS SQL database | [Tutorial](https://developers.sap.com/tutorials/cp-kyma-api-mssql-golang.html) | -| [Use and Seed SAP BTP PostgreSQL in SAP BTP, Kyma Runtime](./database-postgres/README.md) | This sample demonstrates how to containerize and deploy an MS SQL database | [Tutorial](https://developers.sap.com/tutorials/cp-kyma-mssql-deployment.html) | +| [Deploy a Go PostgreSQL API Endpoint in SAP BTP, Kyma Runtime](./api-postgresql-go/README.md) | This sample provides a Golang API endpoint for communication with an PostgreSQL database | [Tutorial](https://developers.sap.com/tutorials/cp-kyma-api-postgres-golang.html) | +| [Use and Seed SAP BTP PostgreSQL in SAP BTP, Kyma Runtime](./database-postgres/README.md) | This sample demonstrates how to seed the PostgreSQL database with sample schema and data using a Kubernetes Job | [Tutorial](https://developers.sap.com/tutorials/cp-kyma-postgres-seed.html) | ## Advanced Scenarios diff --git a/movies-rest/.env b/movies-rest/.env new file mode 100644 index 00000000..c0d677a4 --- /dev/null +++ b/movies-rest/.env @@ -0,0 +1,2 @@ +BPL_JVM_THREAD_COUNT=20 +JAVA_TOOL_OPTIONS=-XX:ReservedCodeCacheSize=40M -XX:MaxMetaspaceSize=80M -Xss512k \ No newline at end of file diff --git a/movies-rest/README.md b/movies-rest/README.md new file mode 100644 index 00000000..dadb9a07 --- /dev/null +++ b/movies-rest/README.md @@ -0,0 +1,65 @@ +# Deploy a Spring Boot Movies REST API in SAP BTP, Kyma Runtime + +## Overview + +> [!NOTE] +> This sample is used in the Fast Prototyping in SAP BTP, Kyma Runtime Using App Push tutorial. + +This sample provides a Spring Boot REST API that manages movie records stored as JSON objects in an S3-compatible **SAP Object Store** service. + +This sample demonstrates how to: + +- How to go from source code to a running, externally accessible application on Kyma runtime in a single command +- How to iterate quickly on a prototype without writing Kubernetes manifests, Dockerfiles, or configuring a container registry +- How to evolve a local prototype into an automated GitHub Actions CD pipeline + +## Architecture + +``` +GitHub Actions (CI/CD) + │ + ▼ +Kyma Runtime (Kubernetes) + ├── movies-rest Pod (Spring Boot, port 8080) + │ └── Istio sidecar (mTLS + ingress) + └── SAP Service Operator + └── ObjectStore ServiceInstance → S3 bucket +``` + +Each movie is stored as a JSON file at `movies/.json` inside the bound S3 bucket. No relational database is required. + +## Tech Stack + +| Layer | Technology | +|---|---| +| Language | Java 21 | +| Framework | Spring Boot 3.3 | +| API docs | springdoc-openapi / Swagger UI | +| Storage | AWS SDK v2 → SAP Object Store (S3-compatible) | +| Service binding | `java-sap-service-operator` (SAP Cloud Service Binding) | +| Runtime | SAP BTP Kyma (Kubernetes + Istio) | +| CI/CD | GitHub Actions + `kyma-project/setup-kyma-cli` | + +## API Endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/movies` | List all movies | +| `GET` | `/movies/{id}` | Get a movie by ID | +| `POST` | `/movies` | Create a new movie (ID auto-generated) | +| `PUT` | `/movies/{id}` | Update an existing movie | +| `DELETE` | `/movies/{id}` | Delete a movie | + +Interactive documentation is available at `/swagger-ui.html` after deployment. + +### Movie Resource + +```json +{ + "id": "4e92e9c6-ebe3-4840-ae3c-2ede35ee4b74", + "title": "Blade Runner", + "year": 1982, + "director": "Ridley Scott", + "rating": 8.1 +} +``` diff --git a/movies-rest/pom.xml b/movies-rest/pom.xml new file mode 100644 index 00000000..74d16a7b --- /dev/null +++ b/movies-rest/pom.xml @@ -0,0 +1,62 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + com.example + movies + 1.0.0 + + + 21 + + + + + + com.sap.cloud.environment.servicebinding + java-bom + 0.10.5 + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.5.0 + + + com.sap.cloud.environment.servicebinding + java-sap-service-operator + + + software.amazon.awssdk + s3 + 2.25.0 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/movies-rest/src/main/java/com/example/movies/Application.java b/movies-rest/src/main/java/com/example/movies/Application.java new file mode 100644 index 00000000..8a9e2102 --- /dev/null +++ b/movies-rest/src/main/java/com/example/movies/Application.java @@ -0,0 +1,17 @@ +package com.example.movies; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@OpenAPIDefinition(info = @Info( + title = "Movies API", + version = "1.0.0", + description = "CRUD REST service for movies, backed by SAP BTP Object Store")) +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} \ No newline at end of file diff --git a/movies-rest/src/main/java/com/example/movies/Movie.java b/movies-rest/src/main/java/com/example/movies/Movie.java new file mode 100644 index 00000000..94c9270f --- /dev/null +++ b/movies-rest/src/main/java/com/example/movies/Movie.java @@ -0,0 +1,20 @@ +package com.example.movies; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Movie resource") +public record Movie( + @Schema(description = "Auto-generated ID", example = "1714900000000", accessMode = Schema.AccessMode.READ_ONLY) + String id, + @Schema(description = "Movie title", example = "Blade Runner") + String title, + @Schema(description = "Release year", example = "1982") + int year, + @Schema(description = "Director name", example = "Ridley Scott") + String director, + @Schema(description = "Rating out of 10", example = "8.1") + Double rating) { + public Movie withId(String newId) { + return new Movie(newId, title, year, director, rating); + } +} \ No newline at end of file diff --git a/movies-rest/src/main/java/com/example/movies/MovieController.java b/movies-rest/src/main/java/com/example/movies/MovieController.java new file mode 100644 index 00000000..44614bad --- /dev/null +++ b/movies-rest/src/main/java/com/example/movies/MovieController.java @@ -0,0 +1,102 @@ +package com.example.movies; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/movies") +@Tag(name = "Movies", description = "CRUD operations for movie resources") +public class MovieController { + + private final S3Client s3; + private final String bucket; + private final ObjectMapper mapper = new ObjectMapper(); + + public MovieController(S3Client s3, String bucketName) { + this.s3 = s3; + this.bucket = bucketName; + } + + @GetMapping + @Operation(summary = "List all movies") + public List list() throws IOException { + ListObjectsV2Request request = ListObjectsV2Request.builder() + .bucket(bucket) + .prefix("movies/") + .build(); + ListObjectsV2Response response = s3.listObjectsV2(request); + return response.contents().stream() + .map(obj -> getMovie(obj.key())) + .toList(); + } + + @GetMapping("/{id}") + @Operation(summary = "Get a movie by ID") + public Movie get(@PathVariable String id) { + return getMovie("movies/" + id + ".json"); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Create a new movie") + public Movie create(@org.springframework.web.bind.annotation.RequestBody Movie movie) throws Exception { + Movie saved = movie.withId(UUID.randomUUID().toString()); + putMovie(saved); + return saved; + } + + @PutMapping("/{id}") + @Operation(summary = "Update an existing movie") + public Movie update(@PathVariable String id, @org.springframework.web.bind.annotation.RequestBody Movie movie) throws Exception { + Movie saved = movie.withId(id); + putMovie(saved); + return saved; + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete a movie") + public void delete(@PathVariable String id) { + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key("movies/" + id + ".json") + .build(); + s3.deleteObject(request); + } + + private void putMovie(Movie movie) throws Exception { + byte[] json = mapper.writeValueAsBytes(movie); + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key("movies/" + movie.id() + ".json") + .contentType("application/json") + .build(); + s3.putObject(request, software.amazon.awssdk.core.sync.RequestBody.fromBytes(json)); + } + + private Movie getMovie(String key) { + try { + GetObjectRequest request = GetObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + byte[] data = s3.getObject(request).readAllBytes(); + return mapper.readValue(data, Movie.class); + } catch (NoSuchKeyException e) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Movie not found"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} \ No newline at end of file diff --git a/movies-rest/src/main/java/com/example/movies/ObjectStoreConfig.java b/movies-rest/src/main/java/com/example/movies/ObjectStoreConfig.java new file mode 100644 index 00000000..e932978e --- /dev/null +++ b/movies-rest/src/main/java/com/example/movies/ObjectStoreConfig.java @@ -0,0 +1,49 @@ +package com.example.movies; + +import com.sap.cloud.environment.servicebinding.api.DefaultServiceBindingAccessor; +import com.sap.cloud.environment.servicebinding.api.ServiceBinding; +import com.sap.cloud.environment.servicebinding.api.ServiceBindingAccessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import java.net.URI; +import java.util.Map; + +@Configuration +public class ObjectStoreConfig { + + @Bean + public S3Client s3Client() { + ServiceBindingAccessor accessor = DefaultServiceBindingAccessor.getInstance(); + + ServiceBinding binding = accessor.getServiceBindings().stream() + .filter(b -> "objectstore".equals(b.getServiceName().orElse(null))) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No matching Object Store binding found")); + + Map creds = binding.getCredentials(); + + return S3Client.builder() + .region(Region.of((String) creds.get("region"))) + .endpointOverride(URI.create("https://" + creds.get("host"))) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create( + (String) creds.get("access_key_id"), + (String) creds.get("secret_access_key")))) + .build(); + } + + @Bean + public String bucketName() { + ServiceBindingAccessor accessor = DefaultServiceBindingAccessor.getInstance(); + ServiceBinding binding = accessor.getServiceBindings().stream() + .filter(b -> "objectstore".equals(b.getServiceName().orElse(null))) + .findFirst() + .orElseThrow(); + return (String) binding.getCredentials().get("bucket"); + } +} \ No newline at end of file diff --git a/movies-rest/src/main/resources/application.properties b/movies-rest/src/main/resources/application.properties new file mode 100644 index 00000000..a3ac65ce --- /dev/null +++ b/movies-rest/src/main/resources/application.properties @@ -0,0 +1 @@ +server.port=8080 \ No newline at end of file