Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions movies-rest/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
BPL_JVM_THREAD_COUNT=20
JAVA_TOOL_OPTIONS=-XX:ReservedCodeCacheSize=40M -XX:MaxMetaspaceSize=80M -Xss512k
65 changes: 65 additions & 0 deletions movies-rest/README.md
Original file line number Diff line number Diff line change
@@ -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/<id>.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
}
```
62 changes: 62 additions & 0 deletions movies-rest/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?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>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>

<groupId>com.example</groupId>
<artifactId>movies</artifactId>
<version>1.0.0</version>

<properties>
<java.version>21</java.version>
</properties>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.sap.cloud.environment.servicebinding</groupId>
<artifactId>java-bom</artifactId>
<version>0.10.5</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
<dependency>
<groupId>com.sap.cloud.environment.servicebinding</groupId>
<artifactId>java-sap-service-operator</artifactId>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.25.0</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
17 changes: 17 additions & 0 deletions movies-rest/src/main/java/com/example/movies/Application.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
20 changes: 20 additions & 0 deletions movies-rest/src/main/java/com/example/movies/Movie.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
102 changes: 102 additions & 0 deletions movies-rest/src/main/java/com/example/movies/MovieController.java
Original file line number Diff line number Diff line change
@@ -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<Movie> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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");
}
}
1 change: 1 addition & 0 deletions movies-rest/src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
server.port=8080
Loading