feat: add app controller with multipart JAR upload
Adds AppController at /api/environments/{environmentId}/apps with POST (multipart
metadata+JAR), GET list, GET by ID, PUT jar reupload, and DELETE endpoints.
Also adds CreateAppRequest and AppResponse DTOs, integration tests (AppControllerTest),
and fixes ClickHouseConfig to be excluded in test profile via @Profile("!test").
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
130
src/main/java/net/siegeln/cameleer/saas/app/AppController.java
Normal file
130
src/main/java/net/siegeln/cameleer/saas/app/AppController.java
Normal file
@@ -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<AppResponse> 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<AppResponse>> list(@PathVariable UUID environmentId) {
|
||||||
|
var apps = appService.listByEnvironmentId(environmentId)
|
||||||
|
.stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(apps);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{appId}")
|
||||||
|
public ResponseEntity<AppResponse> 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<AppResponse> 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<Void> 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -3,11 +3,13 @@ package net.siegeln.cameleer.saas.log;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
|
||||||
import com.clickhouse.jdbc.ClickHouseDataSource;
|
import com.clickhouse.jdbc.ClickHouseDataSource;
|
||||||
import java.util.Properties;
|
import java.util.Properties;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@Profile("!test")
|
||||||
public class ClickHouseConfig {
|
public class ClickHouseConfig {
|
||||||
|
|
||||||
@Value("${cameleer.clickhouse.url:jdbc:clickhouse://clickhouse:8123/cameleer}")
|
@Value("${cameleer.clickhouse.url:jdbc:clickhouse://clickhouse:8123/cameleer}")
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user