diff --git a/java-springboot/.dockerignore b/java-springboot/.dockerignore new file mode 100644 index 0000000..77d9707 --- /dev/null +++ b/java-springboot/.dockerignore @@ -0,0 +1,55 @@ +# Compiled class files +target/ +*.class + +# Log files +*.log + +# Package files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# Virtual machine crash logs +hs_err_pid* + +# IDE files +.idea/ +.vscode/ +*.iml +*.ipr +*.iws + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Maven +.mvn/ +mvnw +mvnw.cmd + +# Git +.git/ +.gitignore + +# Documentation +README.md +*.md + +# Suga files +.suga/ +suga.yaml +suga_gen/ + +# Test files +user_names.txt \ No newline at end of file diff --git a/java-springboot/Dockerfile b/java-springboot/Dockerfile new file mode 100644 index 0000000..327cb02 --- /dev/null +++ b/java-springboot/Dockerfile @@ -0,0 +1,48 @@ +# Multi-stage Docker build for Java Spring Boot application + +# Build stage +FROM maven:3.8.4-openjdk-8-slim AS build + +# Set working directory +WORKDIR /app + +# Copy Maven configuration files +COPY pom.xml . + +# Download dependencies (this layer will be cached if pom.xml doesn't change) +RUN mvn dependency:go-offline -B + +# Copy source code +COPY src ./src + +# Build the application +RUN mvn clean package -DskipTests + +# Runtime stage +FROM openjdk:8-jre-slim + +# Set working directory +WORKDIR /app + +# Create a non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Copy the built JAR from build stage +COPY --from=build /app/target/hello-world-api-1.0.0.jar app.jar + +# Change ownership to appuser +RUN chown -R appuser:appuser /app +USER appuser + +# Expose port 8080 +EXPOSE 8080 + +# Set JVM options for containerized environment +ENV JAVA_OPTS="-Xmx512m -Xms256m" + +# Health check - using a simple endpoint since actuator is not included +HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/ || exit 1 + +# Run the application +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/java-springboot/README.md b/java-springboot/README.md new file mode 100644 index 0000000..8f0b392 --- /dev/null +++ b/java-springboot/README.md @@ -0,0 +1,108 @@ +# Hello World API Server (Java) + +This project contains a Hello World API server built with Java and Spring Boot framework. + +## Features + +- RESTful API endpoints for Hello World functionality +- Integration with Suga to manage infrastructure +- Docker containerization +- Logging configuration with Logback + +## Files Structure + +- `pom.xml` - Maven build configuration with Spring Boot dependencies +- `src/main/java/com/example/helloworldapi/HelloWorldApiApplication.java` - Main Spring Boot application +- `src/main/java/com/example/helloworldapi/controller/HelloWorldController.java` - REST controller +- `src/main/resources/application.properties` - Application configuration +- `src/main/resources/logback.xml` - Logging configuration +- `Dockerfile` - Multi-stage Docker build configuration +- `.dockerignore` - Docker ignore patterns + +## Prerequisites + +- Java 8 or later +- Maven 3.6 or later (or use Maven wrapper) +- Internet connection for downloading dependencies + +## To run locally + +To run the application locally using the Suga development environment: + +```bash +cd java-springboot +suga dev +``` + +This command will start the Spring Boot server on port 4000. + +Alternatively, you can run it directly using Maven: + +```bash +cd java-springboot +./mvnw spring-boot:run +``` + +Or using Maven directly: +```bash +cd java-springboot +mvn spring-boot:run +``` + +## To build + +```bash +cd java-springboot +./mvnw clean install +``` + +## API Endpoints + +- `GET /api/hello` - Returns "Hello, World!" +- `GET /api/hello/name?name=YourName` - Returns "Hello, YourName!" and logs the name to S3 bucket +- `GET /api/` - Returns welcome message with available endpoints +- `GET /api/logs` - Returns logged user names from S3 bucket + +## Testing the API + +Once the server is running (either on port 4000 directly or via the Suga Load Balancer on port 3000), you can test the endpoints using: + +### curl + +```bash +# Direct access (if not using Suga dev) +curl http://localhost:4000/api/hello +curl "http://localhost:4000/api/hello/name?name=John" +curl http://localhost:4000/api/logs + +# Via Suga Load Balancer +curl http://localhost:3000/api/hello +curl "http://localhost:3000/api/hello/name?name=John" +curl http://localhost:3000/api/logs +``` + +## S3 Bucket Logging Feature + +The server includes S3 bucket logging functionality using the Suga client: + +- When users access `/api/hello/name?name=YourName` with a custom name (not "World"), the server logs the name to an S3 bucket +- Each entry includes a timestamp and the user's name +- The log file is stored as `user_names.txt` in the configured S3 bucket +- Example log entry: `[2025-10-20 17:52:30] User name: John` +- Access logged entries via the `/api/logs` endpoint + +## Technology Stack + +- **Java** - Popular, high-performance programming language +- **Spring Boot** - Framework for building production-ready Spring applications +- **Maven** - Build automation tool +- **Logback** - Logging framework +- **Suga Client** - Infrastructure Orchestrator +- **Docker** - Containerization + +## Notes + +- The server runs on port 4000 by default (configurable via PORT environment variable) +- The loadbalancer locally uses port 3000 and forwards to the server as needed. +- Spring Boot provides extensive features for rapid application development +- User names are only logged when they differ from the default "World" value \ No newline at end of file diff --git a/java-springboot/pom.xml b/java-springboot/pom.xml new file mode 100644 index 0000000..b457460 --- /dev/null +++ b/java-springboot/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + com.example + hello-world-api + 1.0.0 + jar + + Hello World API + A simple Hello World API server built with Spring Boot + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + + 1.8 + + + + + org.springframework.boot + spring-boot-starter-web + + + + com.addsuga + suga-client + 0.0.1 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + \ No newline at end of file diff --git a/java-springboot/src/main/java/com/example/helloworldapi/HelloWorldApiApplication.java b/java-springboot/src/main/java/com/example/helloworldapi/HelloWorldApiApplication.java new file mode 100644 index 0000000..6a31cc6 --- /dev/null +++ b/java-springboot/src/main/java/com/example/helloworldapi/HelloWorldApiApplication.java @@ -0,0 +1,12 @@ +package com.example.helloworldapi; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class HelloWorldApiApplication { + + public static void main(String[] args) { + SpringApplication.run(HelloWorldApiApplication.class, args); + } +} \ No newline at end of file diff --git a/java-springboot/src/main/java/com/example/helloworldapi/SimpleHttpServer.java b/java-springboot/src/main/java/com/example/helloworldapi/SimpleHttpServer.java new file mode 100644 index 0000000..8827f1a --- /dev/null +++ b/java-springboot/src/main/java/com/example/helloworldapi/SimpleHttpServer.java @@ -0,0 +1,138 @@ +package com.example.helloworldapi; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.io.FileWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +public class SimpleHttpServer { + + private static final String LOG_FILE = "user_names.txt"; + + public static void main(String[] args) throws IOException { + HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); + + // Create API endpoints + server.createContext("/api/hello", new HelloHandler()); + server.createContext("/api/hello/name", new HelloNameHandler()); + server.createContext("/api/", new RootHandler()); + server.createContext("/", new WelcomeHandler()); + + server.setExecutor(null); // creates a default executor + server.start(); + + System.out.println("Hello World API Server started on port 8080"); + System.out.println("Available endpoints:"); + System.out.println(" GET http://localhost:8080/api/hello"); + System.out.println(" GET http://localhost:8080/api/hello/name?name=YourName"); + System.out.println(" GET http://localhost:8080/api/"); + System.out.println(" GET http://localhost:8080/"); + } + + static class HelloHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if ("GET".equals(exchange.getRequestMethod())) { + String response = "Hello, World!"; + sendResponse(exchange, response); + } else { + sendResponse(exchange, "Method not allowed", 405); + } + } + } + + static class HelloNameHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if ("GET".equals(exchange.getRequestMethod())) { + URI requestURI = exchange.getRequestURI(); + String query = requestURI.getQuery(); + String name = "World"; + + if (query != null) { + Map queryParams = parseQuery(query); + name = queryParams.getOrDefault("name", "World"); + } + + // Log the name to file if it's not the default "World" + if (!"World".equals(name)) { + logNameToFile(name); + } + + String response = "Hello, " + name + "!"; + sendResponse(exchange, response); + } else { + sendResponse(exchange, "Method not allowed", 405); + } + } + } + + static class RootHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if ("GET".equals(exchange.getRequestMethod())) { + String response = "Welcome to the Hello World API! Try /api/hello or /api/hello/name?name=YourName"; + sendResponse(exchange, response); + } else { + sendResponse(exchange, "Method not allowed", 405); + } + } + } + + static class WelcomeHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + if ("GET".equals(exchange.getRequestMethod())) { + String response = "Hello World API Server is running! Visit /api/ for API endpoints."; + sendResponse(exchange, response); + } else { + sendResponse(exchange, "Method not allowed", 405); + } + } + } + + private static void sendResponse(HttpExchange exchange, String response) throws IOException { + sendResponse(exchange, response, 200); + } + + private static void sendResponse(HttpExchange exchange, String response, int statusCode) throws IOException { + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8"); + exchange.sendResponseHeaders(statusCode, response.getBytes().length); + OutputStream os = exchange.getResponseBody(); + os.write(response.getBytes()); + os.close(); + } + + private static void logNameToFile(String name) { + try (FileWriter writer = new FileWriter(LOG_FILE, true)) { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + writer.write(String.format("[%s] User name: %s%n", timestamp, name)); + System.out.println("Logged name to file: " + name); + } catch (IOException e) { + System.err.println("Error writing to log file: " + e.getMessage()); + } + } + + private static Map parseQuery(String query) { + Map result = new HashMap<>(); + if (query != null) { + String[] pairs = query.split("&"); + for (String pair : pairs) { + String[] keyValue = pair.split("="); + if (keyValue.length == 2) { + result.put(keyValue[0], keyValue[1]); + } + } + } + return result; + } +} \ No newline at end of file diff --git a/java-springboot/src/main/java/com/example/helloworldapi/controller/HelloWorldController.java b/java-springboot/src/main/java/com/example/helloworldapi/controller/HelloWorldController.java new file mode 100644 index 0000000..ad547f7 --- /dev/null +++ b/java-springboot/src/main/java/com/example/helloworldapi/controller/HelloWorldController.java @@ -0,0 +1,80 @@ +package com.example.helloworldapi.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.addsuga.SugaClient; +import com.addsuga.Bucket; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@RestController +@RequestMapping("/api") +public class HelloWorldController { + + private final SugaClient sugaClient; + private final Bucket imageBucket; + private static final String LOG_KEY = "user_names.txt"; + + public HelloWorldController() { + this.sugaClient = new SugaClient(); + this.imageBucket = this.sugaClient.createBucket("image"); + } + + @GetMapping("/hello") + public String hello() { + return "Hello, World!"; + } + + @GetMapping("/hello/name") + public String helloName(@RequestParam(value = "name", defaultValue = "World") String name) { + // Log the name to S3 bucket if it's not the default "World" + if (!"World".equals(name)) { + logNameToBucket(name); + } + return String.format("Hello, %s!", name); + } + + @GetMapping("/") + public String root() { + return "Welcome to the Hello World API! Try /api/hello or /api/hello/name?name=YourName"; + } + + @GetMapping("/logs") + public String getLogs() { + try { + byte[] logsData = imageBucket.read(LOG_KEY); + String logs = new String(logsData); + return "User logs from S3 bucket:\n" + logs; + } catch (Exception e) { + System.err.println("Error reading from S3 bucket: " + e.getMessage()); + return "Error reading logs from S3 bucket: " + e.getMessage(); + } + } + + private void logNameToBucket(String name) { + try { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + String logEntry = String.format("[%s] User name: %s%n", timestamp, name); + + // Read existing logs and append new entry + String existingLogs = ""; + try { + byte[] existingData = imageBucket.read(LOG_KEY); + existingLogs = new String(existingData); + } catch (Exception e) { + // If file doesn't exist or can't be read, start with empty string + System.out.println("Starting new log file in S3 bucket"); + } + + String updatedLogs = existingLogs + logEntry; + imageBucket.write(LOG_KEY, updatedLogs.getBytes()); + + System.out.println("Logged name to S3 bucket: " + name); + } catch (Exception e) { + System.err.println("Error writing to S3 bucket: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/java-springboot/src/main/resources/application.properties b/java-springboot/src/main/resources/application.properties new file mode 100644 index 0000000..ef8a740 --- /dev/null +++ b/java-springboot/src/main/resources/application.properties @@ -0,0 +1,13 @@ +# Server Configuration +server.port=${PORT:4000} +server.servlet.context-path=/ + +# Application Configuration +spring.application.name=hello-world-api + +# Logging Configuration +logging.level.com.example.helloworldapi=INFO +logging.level.org.springframework.web=INFO + +# Banner Configuration +spring.main.banner-mode=console \ No newline at end of file diff --git a/java-springboot/suga.yaml b/java-springboot/suga.yaml new file mode 100644 index 0000000..bd5ee0a --- /dev/null +++ b/java-springboot/suga.yaml @@ -0,0 +1,25 @@ +target: suga/aws@1 +name: java-springboot +description: Test project in java +services: + backend_api: + subtype: lambda + container: + docker: + dockerfile: Dockerfile + context: . + dev: + script: mvn spring-boot:run +buckets: + image: + subtype: s3 + access: + backend_api: + - read + - write +entrypoints: + entrypoint: + subtype: cloudfront + routes: + /: + name: backend_api diff --git a/java-springboot/suga_gen/GeneratedSugaClient.java b/java-springboot/suga_gen/GeneratedSugaClient.java new file mode 100644 index 0000000..07b9f04 --- /dev/null +++ b/java-springboot/suga_gen/GeneratedSugaClient.java @@ -0,0 +1,37 @@ +// Code generated by Suga SDK generator. DO NOT EDIT. + +package com.example.helloworldapi; + +import com.addsuga.client.SugaClient; + +import com.addsuga.client.Bucket; + + +/** + * Generated client provides access to suga application resources + */ +public class GeneratedSugaClient extends SugaClient { + + private final Bucket image; + + + public GeneratedSugaClient() { + super(); + + this.image = createBucket("image"); + + } + + public GeneratedSugaClient(String address) { + super(address); + + this.image = createBucket("image"); + + } + + + public Bucket getImage() { + return this.image; + } + +} \ No newline at end of file diff --git a/kotlin-ktor/.dockerignore b/kotlin-ktor/.dockerignore new file mode 100644 index 0000000..1de8372 --- /dev/null +++ b/kotlin-ktor/.dockerignore @@ -0,0 +1,54 @@ +# Compiled class files +build/ +*.class + +# Log files +*.log + +# Package files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# Virtual machine crash logs +hs_err_pid* + +# IDE files +.idea/ +.vscode/ +*.iml +*.ipr +*.iws + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Gradle +.gradle/ +gradle/wrapper/ + +# Git +.git/ +.gitignore + +# Documentation +README.md +*.md + +# Suga files +.suga/ +suga.yaml +suga_gen/ + +# Test files +user_names.txt \ No newline at end of file diff --git a/kotlin-ktor/.gradle/8.5/checksums/checksums.lock b/kotlin-ktor/.gradle/8.5/checksums/checksums.lock new file mode 100644 index 0000000..0163667 Binary files /dev/null and b/kotlin-ktor/.gradle/8.5/checksums/checksums.lock differ diff --git a/kotlin-ktor/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock b/kotlin-ktor/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock new file mode 100644 index 0000000..653a3d1 Binary files /dev/null and b/kotlin-ktor/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock differ diff --git a/kotlin-ktor/.gradle/8.5/dependencies-accessors/gc.properties b/kotlin-ktor/.gradle/8.5/dependencies-accessors/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/kotlin-ktor/.gradle/8.5/executionHistory/executionHistory.lock b/kotlin-ktor/.gradle/8.5/executionHistory/executionHistory.lock new file mode 100644 index 0000000..f97d052 Binary files /dev/null and b/kotlin-ktor/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/kotlin-ktor/.gradle/8.5/fileChanges/last-build.bin b/kotlin-ktor/.gradle/8.5/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/kotlin-ktor/.gradle/8.5/fileChanges/last-build.bin differ diff --git a/kotlin-ktor/.gradle/8.5/fileHashes/fileHashes.lock b/kotlin-ktor/.gradle/8.5/fileHashes/fileHashes.lock new file mode 100644 index 0000000..e9b41c6 Binary files /dev/null and b/kotlin-ktor/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/kotlin-ktor/.gradle/8.5/gc.properties b/kotlin-ktor/.gradle/8.5/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/kotlin-ktor/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/kotlin-ktor/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..79096af Binary files /dev/null and b/kotlin-ktor/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/kotlin-ktor/.gradle/buildOutputCleanup/cache.properties b/kotlin-ktor/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..40308cb --- /dev/null +++ b/kotlin-ktor/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Tue Oct 21 12:26:59 AEDT 2025 +gradle.version=8.5 diff --git a/kotlin-ktor/.gradle/buildOutputCleanup/outputFiles.bin b/kotlin-ktor/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 0000000..3569130 Binary files /dev/null and b/kotlin-ktor/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/kotlin-ktor/.gradle/vcs-1/gc.properties b/kotlin-ktor/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/kotlin-ktor/Dockerfile b/kotlin-ktor/Dockerfile new file mode 100644 index 0000000..5c7f3f8 --- /dev/null +++ b/kotlin-ktor/Dockerfile @@ -0,0 +1,48 @@ +# Multi-stage Docker build for Kotlin Ktor application + +# Build stage +FROM gradle:8.5-jdk8 AS build + +# Set working directory +WORKDIR /app + +# Copy Gradle configuration files +COPY build.gradle.kts gradle.properties ./ + +# Download dependencies (this layer will be cached if build files don't change) +RUN gradle dependencies --no-daemon + +# Copy source code +COPY src ./src + +# Build the application +RUN gradle build --no-daemon + +# Runtime stage +FROM openjdk:8-jre-slim + +# Set working directory +WORKDIR /app + +# Create a non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Copy the built JAR from build stage +COPY --from=build /app/build/libs/kotlin-ktor-1.0.0-all.jar app.jar + +# Change ownership to appuser +RUN chown -R appuser:appuser /app +USER appuser + +# Expose port 8080 +EXPOSE 8080 + +# Set JVM options for containerized environment +ENV JAVA_OPTS="-Xmx512m -Xms256m" + +# Health check - using a simple endpoint +HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/ || exit 1 + +# Run the application +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] \ No newline at end of file diff --git a/kotlin-ktor/README.md b/kotlin-ktor/README.md new file mode 100644 index 0000000..40cad0e --- /dev/null +++ b/kotlin-ktor/README.md @@ -0,0 +1,108 @@ +# Hello World API Server (Kotlin) + +This project contains a Hello World API server built with Kotlin and Ktor framework. + +## Features + +- RESTful API endpoints for Hello World functionality +- Integration with Suga to manage infrastructure +- Docker containerization +- Logging configuration with Logback + +## Files Structure + +- `build.gradle.kts` - Gradle build configuration with Kotlin DSL +- `gradle.properties` - Gradle properties with version definitions +- `src/main/kotlin/com/example/helloworldapi/Application.kt` - Main Ktor application +- `src/main/resources/application.conf` - Application configuration +- `src/main/resources/logback.xml` - Logging configuration +- `Dockerfile` - Multi-stage Docker build configuration +- `.dockerignore` - Docker ignore patterns + +## Prerequisites + +- Java 8 or later +- Gradle 8.4 or later (or use Gradle wrapper) +- Internet connection for downloading dependencies + +## To run locally + +To run the application locally using the Suga development environment: + +```bash +cd kotlin-ktor +suga dev +``` + +This command will start the Ktor server on port 4000. + +Alternatively, you can run it directly using Gradle: + +```bash +cd kotlin-ktor +./gradlew run +``` + +Or using Gradle directly: +```bash +cd kotlin-ktor +gradle run +``` + +## To build + +```bash +cd kotlin-ktor +./gradlew build +``` + +## API Endpoints + +- `GET /api/hello` - Returns "Hello, World!" +- `GET /api/hello/name?name=YourName` - Returns "Hello, YourName!" and logs the name to S3 bucket +- `GET /api/` - Returns welcome message with available endpoints +- `GET /api/logs` - Returns logged user names from S3 bucket + +## Testing the API + +Once the server is running (either on port 4000 directly or via the Suga Load Balancer on port 3000), you can test the endpoints using: + +### curl + +```bash +# Direct access (if not using Suga dev) +curl http://localhost:4000/api/hello +curl "http://localhost:4000/api/hello/name?name=John" +curl http://localhost:4000/api/logs + +# Via Suga Load Balancer +curl http://localhost:3000/api/hello +curl "http://localhost:3000/api/hello/name?name=John" +curl http://localhost:3000/api/logs +``` + +## S3 Bucket Logging Feature + +The server includes S3 bucket logging functionality using the Suga client: + +- When users access `/api/hello/name?name=YourName` with a custom name (not "World"), the server logs the name to an S3 bucket +- Each entry includes a timestamp and the user's name +- The log file is stored as `user_names.txt` in the configured S3 bucket +- Example log entry: `[2025-10-20 17:52:30] User name: John` +- Access logged entries via the `/api/logs` endpoint + +## Technology Stack + +- **Kotlin** - Modern JVM language with concise syntax +- **Ktor** - Asynchronous framework for building connected applications +- **Gradle** - Build automation tool with Kotlin DSL +- **Logback** - Logging framework +- **Suga Client** - Infrastructure Orchestrator +- **Docker** - Containerization + +## Notes + +- The server runs on port 4000 by default (configurable via PORT environment variable) +- The loadbalancer locally uses port 3000 and forwards to the server as needed. +- Ktor provides built-in support for asynchronous request handling +- User names are only logged when they differ from the default "World" value diff --git a/kotlin-ktor/bin/main/application.conf b/kotlin-ktor/bin/main/application.conf new file mode 100644 index 0000000..108e0f9 --- /dev/null +++ b/kotlin-ktor/bin/main/application.conf @@ -0,0 +1,10 @@ +ktor { + application { + modules = [ com.example.helloworldapi.ApplicationKt.module ] + } + deployment { + port = 4000 + port = ${?PORT} + host = 0.0.0.0 + } +} \ No newline at end of file diff --git a/kotlin-ktor/bin/main/com/example/helloworldapi/Application.kt b/kotlin-ktor/bin/main/com/example/helloworldapi/Application.kt new file mode 100644 index 0000000..b1eb99a --- /dev/null +++ b/kotlin-ktor/bin/main/com/example/helloworldapi/Application.kt @@ -0,0 +1,84 @@ +package com.example.helloworldapi + +import com.addsuga.SugaClient +import com.addsuga.Bucket +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +import io.ktor.server.netty.EngineMain + +fun main(args: Array): Unit = EngineMain.main(args) + +fun Application.module() { + configureRouting() +} + +fun Application.configureRouting() { + val sugaClient = SugaClient() + val imageBucket = sugaClient.createBucket("image") + val logKey = "user_names.txt" + + routing { + route("/api") { + get("/hello") { + call.respondText("Hello, World!") + } + + get("/hello/name") { + val name = call.request.queryParameters["name"] ?: "World" + + // Log the name to S3 bucket if it's not the default "World" + if (name != "World") { + logNameToBucket(imageBucket, logKey, name) + } + + call.respondText("Hello, $name!") + } + + get("/") { + call.respondText("Welcome to the Hello World API! Try /api/hello or /api/hello/name?name=YourName") + } + + get("/logs") { + try { + val logsData = imageBucket.read(logKey) + val logs = String(logsData) + call.respondText("User logs from S3 bucket:\n$logs") + } catch (e: Exception) { + println("Error reading from S3 bucket: ${e.message}") + call.respondText("Error reading logs from S3 bucket: ${e.message}", status = HttpStatusCode.InternalServerError) + } + } + } + } +} + +private fun logNameToBucket(imageBucket: Bucket, logKey: String, name: String) { + try { + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + val logEntry = "[$timestamp] User name: $name\n" + + // Read existing logs and append new entry + val existingLogs = try { + val existingData = imageBucket.read(logKey) + String(existingData) + } catch (e: Exception) { + // If file doesn't exist or can't be read, start with empty string + println("Starting new log file in S3 bucket") + "" + } + + val updatedLogs = existingLogs + logEntry + imageBucket.write(logKey, updatedLogs.toByteArray()) + + println("Logged name to S3 bucket: $name") + } catch (e: Exception) { + println("Error writing to S3 bucket: ${e.message}") + } +} \ No newline at end of file diff --git a/kotlin-ktor/bin/main/logback.xml b/kotlin-ktor/bin/main/logback.xml new file mode 100644 index 0000000..925052b --- /dev/null +++ b/kotlin-ktor/bin/main/logback.xml @@ -0,0 +1,14 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file diff --git a/kotlin-ktor/build.gradle.kts b/kotlin-ktor/build.gradle.kts new file mode 100644 index 0000000..afab8b5 --- /dev/null +++ b/kotlin-ktor/build.gradle.kts @@ -0,0 +1,39 @@ +val ktor_version: String by project +val kotlin_version: String by project +val logback_version: String by project + +plugins { + kotlin("jvm") version "1.8.22" + id("io.ktor.plugin") version "2.3.5" + id("org.jetbrains.kotlin.plugin.serialization") version "1.8.22" + application +} + +group = "com.example" +version = "1.0.0" + +application { + mainClass.set("com.example.helloworldapi.ApplicationKt") + + val isDevelopment: Boolean = project.ext.has("development") + applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment") +} + +repositories { + mavenCentral() +} + +dependencies { + implementation("io.ktor:ktor-server-core-jvm") + implementation("io.ktor:ktor-server-netty-jvm") + implementation("io.ktor:ktor-server-config-yaml") + implementation("io.ktor:ktor-server-content-negotiation-jvm") + implementation("io.ktor:ktor-serialization-kotlinx-json-jvm") + implementation("ch.qos.logback:logback-classic:$logback_version") + + // Suga client dependency (equivalent to the Java version) + implementation("com.addsuga:suga-client:0.0.1") + + testImplementation("io.ktor:ktor-server-tests-jvm") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version") +} \ No newline at end of file diff --git a/kotlin-ktor/gradle.properties b/kotlin-ktor/gradle.properties new file mode 100644 index 0000000..8cde68c --- /dev/null +++ b/kotlin-ktor/gradle.properties @@ -0,0 +1,4 @@ +ktor_version=2.3.5 +kotlin_version=1.8.22 +logback_version=1.4.11 +kotlin.code.style=official \ No newline at end of file diff --git a/kotlin-ktor/gradle/wrapper/gradle-wrapper.jar b/kotlin-ktor/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/kotlin-ktor/gradle/wrapper/gradle-wrapper.jar differ diff --git a/kotlin-ktor/gradle/wrapper/gradle-wrapper.properties b/kotlin-ktor/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..7cf0814 --- /dev/null +++ b/kotlin-ktor/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/kotlin-ktor/gradlew b/kotlin-ktor/gradlew new file mode 100644 index 0000000..cc44973 --- /dev/null +++ b/kotlin-ktor/gradlew @@ -0,0 +1,247 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments). +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" \ No newline at end of file diff --git a/kotlin-ktor/gradlew.bat b/kotlin-ktor/gradlew.bat new file mode 100644 index 0000000..c4e5997 --- /dev/null +++ b/kotlin-ktor/gradlew.bat @@ -0,0 +1,90 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd_ return code. Not all Java distributions support $GRADLE_EXIT_CONSOLE. +if not "" == "%GRADLE_EXIT_CONSOLE%" exit %ERRORLEVEL% +exit /b %ERRORLEVEL% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega \ No newline at end of file diff --git a/kotlin-ktor/src/main/kotlin/com/example/helloworldapi/Application.kt b/kotlin-ktor/src/main/kotlin/com/example/helloworldapi/Application.kt new file mode 100644 index 0000000..b1eb99a --- /dev/null +++ b/kotlin-ktor/src/main/kotlin/com/example/helloworldapi/Application.kt @@ -0,0 +1,84 @@ +package com.example.helloworldapi + +import com.addsuga.SugaClient +import com.addsuga.Bucket +import io.ktor.http.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +import io.ktor.server.netty.EngineMain + +fun main(args: Array): Unit = EngineMain.main(args) + +fun Application.module() { + configureRouting() +} + +fun Application.configureRouting() { + val sugaClient = SugaClient() + val imageBucket = sugaClient.createBucket("image") + val logKey = "user_names.txt" + + routing { + route("/api") { + get("/hello") { + call.respondText("Hello, World!") + } + + get("/hello/name") { + val name = call.request.queryParameters["name"] ?: "World" + + // Log the name to S3 bucket if it's not the default "World" + if (name != "World") { + logNameToBucket(imageBucket, logKey, name) + } + + call.respondText("Hello, $name!") + } + + get("/") { + call.respondText("Welcome to the Hello World API! Try /api/hello or /api/hello/name?name=YourName") + } + + get("/logs") { + try { + val logsData = imageBucket.read(logKey) + val logs = String(logsData) + call.respondText("User logs from S3 bucket:\n$logs") + } catch (e: Exception) { + println("Error reading from S3 bucket: ${e.message}") + call.respondText("Error reading logs from S3 bucket: ${e.message}", status = HttpStatusCode.InternalServerError) + } + } + } + } +} + +private fun logNameToBucket(imageBucket: Bucket, logKey: String, name: String) { + try { + val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + val logEntry = "[$timestamp] User name: $name\n" + + // Read existing logs and append new entry + val existingLogs = try { + val existingData = imageBucket.read(logKey) + String(existingData) + } catch (e: Exception) { + // If file doesn't exist or can't be read, start with empty string + println("Starting new log file in S3 bucket") + "" + } + + val updatedLogs = existingLogs + logEntry + imageBucket.write(logKey, updatedLogs.toByteArray()) + + println("Logged name to S3 bucket: $name") + } catch (e: Exception) { + println("Error writing to S3 bucket: ${e.message}") + } +} \ No newline at end of file diff --git a/kotlin-ktor/src/main/resources/application.conf b/kotlin-ktor/src/main/resources/application.conf new file mode 100644 index 0000000..108e0f9 --- /dev/null +++ b/kotlin-ktor/src/main/resources/application.conf @@ -0,0 +1,10 @@ +ktor { + application { + modules = [ com.example.helloworldapi.ApplicationKt.module ] + } + deployment { + port = 4000 + port = ${?PORT} + host = 0.0.0.0 + } +} \ No newline at end of file diff --git a/kotlin-ktor/src/main/resources/logback.xml b/kotlin-ktor/src/main/resources/logback.xml new file mode 100644 index 0000000..925052b --- /dev/null +++ b/kotlin-ktor/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file diff --git a/kotlin-ktor/suga.yaml b/kotlin-ktor/suga.yaml new file mode 100644 index 0000000..c4a7604 --- /dev/null +++ b/kotlin-ktor/suga.yaml @@ -0,0 +1,25 @@ +target: suga/aws@1 +name: kotlin-ktor +description: Test project in kotlin +services: + backend_api: + subtype: lambda + container: + docker: + dockerfile: Dockerfile + context: . + dev: + script: cmd /c gradlew.bat run +buckets: + image: + subtype: s3 + access: + backend_api: + - read + - write +entrypoints: + entrypoint: + subtype: cloudfront + routes: + /: + name: backend_api \ No newline at end of file diff --git a/kotlin-ktor/suga_gen/GeneratedSugaClient.kt b/kotlin-ktor/suga_gen/GeneratedSugaClient.kt new file mode 100644 index 0000000..490ca67 --- /dev/null +++ b/kotlin-ktor/suga_gen/GeneratedSugaClient.kt @@ -0,0 +1,29 @@ +// Code generated by Suga SDK generator. DO NOT EDIT. + +package com.example.helloworldapi + +import com.addsuga.client.SugaClient + +import com.addsuga.client.Bucket + + +/** + * Generated client provides access to suga application resources + */ +class GeneratedSugaClient : SugaClient { + + val image: Bucket + + + constructor() : super() { + + this.image = createBucket("image") + + } + + constructor(address: String) : super(address) { + + this.image = createBucket("image") + + } +} \ No newline at end of file