diff --git a/src/main/java/net/siegeln/cameleer/saas/app/AppController.java b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java new file mode 100644 index 0000000..0206658 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/app/AppController.java @@ -0,0 +1,130 @@ +package net.siegeln.cameleer.saas.app; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.siegeln.cameleer.saas.app.dto.AppResponse; +import net.siegeln.cameleer.saas.app.dto.CreateAppRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/environments/{environmentId}/apps") +public class AppController { + + private final AppService appService; + private final ObjectMapper objectMapper; + + public AppController(AppService appService, ObjectMapper objectMapper) { + this.appService = appService; + this.objectMapper = objectMapper; + } + + @PostMapping(consumes = "multipart/form-data") + public ResponseEntity create( + @PathVariable UUID environmentId, + @RequestPart("metadata") String metadataJson, + @RequestPart("file") MultipartFile file, + Authentication authentication) { + try { + var request = objectMapper.readValue(metadataJson, CreateAppRequest.class); + UUID actorId = resolveActorId(authentication); + var entity = appService.create(environmentId, request.slug(), request.displayName(), file, actorId); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(entity)); + } catch (IllegalArgumentException e) { + var msg = e.getMessage(); + if (msg != null && (msg.contains("already exists") || msg.contains("slug"))) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + return ResponseEntity.badRequest().build(); + } catch (IllegalStateException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } catch (IOException e) { + return ResponseEntity.badRequest().build(); + } + } + + @GetMapping + public ResponseEntity> list(@PathVariable UUID environmentId) { + var apps = appService.listByEnvironmentId(environmentId) + .stream() + .map(this::toResponse) + .toList(); + return ResponseEntity.ok(apps); + } + + @GetMapping("/{appId}") + public ResponseEntity getById( + @PathVariable UUID environmentId, + @PathVariable UUID appId) { + return appService.getById(appId) + .map(entity -> ResponseEntity.ok(toResponse(entity))) + .orElse(ResponseEntity.notFound().build()); + } + + @PutMapping(value = "/{appId}/jar", consumes = "multipart/form-data") + public ResponseEntity reuploadJar( + @PathVariable UUID environmentId, + @PathVariable UUID appId, + @RequestPart("file") MultipartFile file, + Authentication authentication) { + try { + UUID actorId = resolveActorId(authentication); + var entity = appService.reuploadJar(appId, file, actorId); + return ResponseEntity.ok(toResponse(entity)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @DeleteMapping("/{appId}") + public ResponseEntity delete( + @PathVariable UUID environmentId, + @PathVariable UUID appId, + Authentication authentication) { + try { + UUID actorId = resolveActorId(authentication); + appService.delete(appId, actorId); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + private UUID resolveActorId(Authentication authentication) { + String sub = authentication.getName(); + try { + return UUID.fromString(sub); + } catch (IllegalArgumentException e) { + return UUID.nameUUIDFromBytes(sub.getBytes()); + } + } + + private AppResponse toResponse(AppEntity entity) { + return new AppResponse( + entity.getId(), + entity.getEnvironmentId(), + entity.getSlug(), + entity.getDisplayName(), + entity.getJarOriginalFilename(), + entity.getJarSizeBytes(), + entity.getJarChecksum(), + entity.getCurrentDeploymentId(), + entity.getPreviousDeploymentId(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java b/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java new file mode 100644 index 0000000..b0c1c94 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/app/dto/AppResponse.java @@ -0,0 +1,11 @@ +package net.siegeln.cameleer.saas.app.dto; + +import java.time.Instant; +import java.util.UUID; + +public record AppResponse( + UUID id, UUID environmentId, String slug, String displayName, + String jarOriginalFilename, Long jarSizeBytes, String jarChecksum, + UUID currentDeploymentId, UUID previousDeploymentId, + Instant createdAt, Instant updatedAt +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java b/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java new file mode 100644 index 0000000..69ccbe4 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/app/dto/CreateAppRequest.java @@ -0,0 +1,13 @@ +package net.siegeln.cameleer.saas.app.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record CreateAppRequest( + @NotBlank @Size(min = 2, max = 100) + @Pattern(regexp = "^[a-z0-9][a-z0-9-]*[a-z0-9]$", message = "Slug must be lowercase alphanumeric with hyphens") + String slug, + @NotBlank @Size(max = 255) + String displayName +) {} diff --git a/src/main/java/net/siegeln/cameleer/saas/log/ClickHouseConfig.java b/src/main/java/net/siegeln/cameleer/saas/log/ClickHouseConfig.java index 952102f..dd989e3 100644 --- a/src/main/java/net/siegeln/cameleer/saas/log/ClickHouseConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/log/ClickHouseConfig.java @@ -3,11 +3,13 @@ package net.siegeln.cameleer.saas.log; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import com.clickhouse.jdbc.ClickHouseDataSource; import java.util.Properties; @Configuration +@Profile("!test") public class ClickHouseConfig { @Value("${cameleer.clickhouse.url:jdbc:clickhouse://clickhouse:8123/cameleer}") diff --git a/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java new file mode 100644 index 0000000..a055b1b --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/app/AppControllerTest.java @@ -0,0 +1,169 @@ +package net.siegeln.cameleer.saas.app; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.siegeln.cameleer.saas.TestcontainersConfig; +import net.siegeln.cameleer.saas.TestSecurityConfig; +import net.siegeln.cameleer.saas.environment.EnvironmentRepository; +import net.siegeln.cameleer.saas.license.LicenseDefaults; +import net.siegeln.cameleer.saas.license.LicenseEntity; +import net.siegeln.cameleer.saas.license.LicenseRepository; +import net.siegeln.cameleer.saas.tenant.TenantEntity; +import net.siegeln.cameleer.saas.tenant.TenantRepository; +import net.siegeln.cameleer.saas.tenant.Tier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import({TestcontainersConfig.class, TestSecurityConfig.class}) +@ActiveProfiles("test") +class AppControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private AppRepository appRepository; + + @Autowired + private EnvironmentRepository environmentRepository; + + @Autowired + private LicenseRepository licenseRepository; + + @Autowired + private TenantRepository tenantRepository; + + private UUID environmentId; + + @BeforeEach + void setUp() { + appRepository.deleteAll(); + environmentRepository.deleteAll(); + licenseRepository.deleteAll(); + tenantRepository.deleteAll(); + + var tenant = new TenantEntity(); + tenant.setName("Test Org"); + tenant.setSlug("test-org-" + System.nanoTime()); + var savedTenant = tenantRepository.save(tenant); + var tenantId = savedTenant.getId(); + + var license = new LicenseEntity(); + license.setTenantId(tenantId); + license.setTier("MID"); + license.setFeatures(LicenseDefaults.featuresForTier(Tier.MID)); + license.setLimits(LicenseDefaults.limitsForTier(Tier.MID)); + license.setExpiresAt(Instant.now().plus(365, ChronoUnit.DAYS)); + license.setToken("test-token"); + licenseRepository.save(license); + + var env = new net.siegeln.cameleer.saas.environment.EnvironmentEntity(); + env.setTenantId(tenantId); + env.setSlug("default"); + env.setDisplayName("Default"); + env.setBootstrapToken("test-bootstrap-token"); + var savedEnv = environmentRepository.save(env); + environmentId = savedEnv.getId(); + } + + @Test + void createApp_shouldReturn201() throws Exception { + var metadata = new MockMultipartFile("metadata", "", "application/json", + """ + {"slug": "order-svc", "displayName": "Order Service"} + """.getBytes()); + var jar = new MockMultipartFile("file", "order-service.jar", + "application/java-archive", "fake-jar".getBytes()); + + mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps") + .file(jar) + .file(metadata) + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.slug").value("order-svc")) + .andExpect(jsonPath("$.displayName").value("Order Service")); + } + + @Test + void createApp_nonJarFile_shouldReturn400() throws Exception { + var metadata = new MockMultipartFile("metadata", "", "application/json", + """ + {"slug": "order-svc", "displayName": "Order Service"} + """.getBytes()); + var txt = new MockMultipartFile("file", "readme.txt", + "text/plain", "hello".getBytes()); + + mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps") + .file(txt) + .file(metadata) + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isBadRequest()); + } + + @Test + void listApps_shouldReturnAll() throws Exception { + var metadata = new MockMultipartFile("metadata", "", "application/json", + """ + {"slug": "billing-svc", "displayName": "Billing Service"} + """.getBytes()); + var jar = new MockMultipartFile("file", "billing-service.jar", + "application/java-archive", "fake-jar".getBytes()); + + mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps") + .file(jar) + .file(metadata) + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/environments/" + environmentId + "/apps") + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].slug").value("billing-svc")); + } + + @Test + void deleteApp_shouldReturn204() throws Exception { + var metadata = new MockMultipartFile("metadata", "", "application/json", + """ + {"slug": "payment-svc", "displayName": "Payment Service"} + """.getBytes()); + var jar = new MockMultipartFile("file", "payment-service.jar", + "application/java-archive", "fake-jar".getBytes()); + + var createResult = mockMvc.perform(multipart("/api/environments/" + environmentId + "/apps") + .file(jar) + .file(metadata) + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isCreated()) + .andReturn(); + + String appId = objectMapper.readTree(createResult.getResponse().getContentAsString()) + .get("id").asText(); + + mockMvc.perform(delete("/api/environments/" + environmentId + "/apps/" + appId) + .with(jwt().jwt(j -> j.claim("sub", "test-user")))) + .andExpect(status().isNoContent()); + } +}