Compare commits

..

5 Commits

Author SHA1 Message Date
Zakaria 1b00488b7c fix(glue): write timestamps the AWS SDK for JavaScript can read
Glue times went out as nanosecond epoch numbers (1792964720.708701378). The
AWS SDK for JavaScript v3 keeps any number whose text is not its shortest form
as a BigDecimal and then refuses it as a timestamp, so GetTables still failed.
They are now written as AWS writes them: epoch seconds to the millisecond, in
the shortest form. CreateDatabase also records the database's CreateTime.
2026-10-25 17:48:12 -04:00
Zakaria dfbfa4d2a7 fix(glue): leave unset fields out of catalog responses
Database, Table, Partition, UserDefinedFunction, Job, Crawler and LastCrawlInfo
were serialized with every unset field as null. AWS omits them, and the AWS SDK
for JavaScript v3 rejects a null timestamp outright, so GetTables failed for
any table that had never been updated (UpdateTime and LastAccessTime: null).
2026-10-25 17:41:57 -04:00
Zakaria 00b3af3813 Merge pull request 'fix(acm): keep the Export option on RequestCertificate' (#12) from fix/acm-request-export-option into main
Reviewed-on: #12
2026-10-25 17:21:08 +00:00
Zakaria d8d7dd021b fix(acm): keep the Export option on RequestCertificate
RequestCertificate always stored Export as DISABLED, so an exportable public
certificate could never be exported. Read Export and the transparency logging
preference the way UpdateCertificateOptions does, rejecting values other than
ENABLED and DISABLED.
2026-10-25 12:30:11 -04:00
Zakaria eaf5ca6ade Merge pull request 'docs: rebrand the README as TheCloud's AWS runtime' (#11) from docs/readme-thecloud into main
Reviewed-on: #11
2026-10-25 14:29:43 +00:00
15 changed files with 230 additions and 2 deletions
@@ -563,8 +563,10 @@ public class AcmJsonHandler {
private CertificateOptions parseOptions(JsonNode optionsNode) { private CertificateOptions parseOptions(JsonNode optionsNode) {
if (optionsNode.isMissingNode()) return null; if (optionsNode.isMissingNode()) return null;
String ctPref = optionsNode.path("CertificateTransparencyLoggingPreference").asText("ENABLED"); String ctPref = parseOptionValue(optionsNode, "CertificateTransparencyLoggingPreference");
return new CertificateOptions(ctPref, "DISABLED"); String export = parseOptionValue(optionsNode, "Export");
// An exportable public certificate is requested with Export ENABLED, and keeps it for life.
return new CertificateOptions(ctPref != null ? ctPref : "ENABLED", export != null ? export : "DISABLED");
} }
private List<CertificateStatus> parseCertificateStatuses(JsonNode node) { private List<CertificateStatus> parseCertificateStatuses(JsonNode node) {
@@ -243,6 +243,9 @@ public class GlueService {
} }
database.setName(databaseName); database.setName(databaseName);
database.setCatalogId(regionResolver.getAccountId()); database.setCatalogId(regionResolver.getAccountId());
if (database.getCreateTime() == null) {
database.setCreateTime(Instant.now());
}
databaseStore.put(databaseName, database); databaseStore.put(databaseName, database);
if (tags != null && !tags.isEmpty()) { if (tags != null && !tags.isEmpty()) {
resourceGroupsTaggingService.tagResources(List.of(databaseArn(region, databaseName)), tags, region); resourceGroupsTaggingService.tagResources(List.of(databaseArn(region, databaseName)), tags, region);
@@ -3,6 +3,7 @@ package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant; import java.time.Instant;
@@ -47,10 +48,12 @@ public class Connection {
@JsonProperty("CreationTime") @JsonProperty("CreationTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant creationTime; private Instant creationTime;
@JsonProperty("LastUpdatedTime") @JsonProperty("LastUpdatedTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastUpdatedTime; private Instant lastUpdatedTime;
@JsonProperty("LastUpdatedBy") @JsonProperty("LastUpdatedBy")
@@ -64,6 +67,7 @@ public class Connection {
@JsonProperty("LastConnectionValidationTime") @JsonProperty("LastConnectionValidationTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastConnectionValidationTime; private Instant lastConnectionValidationTime;
@JsonProperty("AuthenticationConfiguration") @JsonProperty("AuthenticationConfiguration")
@@ -1,12 +1,15 @@
package io.github.hectorvent.floci.services.glue.model; package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@RegisterForReflection @RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Crawler { public class Crawler {
@JsonProperty("Classifiers") @JsonProperty("Classifiers")
private List<String> classifiers; private List<String> classifiers;
@@ -22,6 +25,7 @@ public class Crawler {
@JsonProperty("CreationTime") @JsonProperty("CreationTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant creationTime; private Instant creationTime;
@JsonProperty("DatabaseName") @JsonProperty("DatabaseName")
@@ -38,6 +42,7 @@ public class Crawler {
@JsonProperty("LastUpdated") @JsonProperty("LastUpdated")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastUpdated; private Instant lastUpdated;
@JsonProperty("LineageConfiguration") @JsonProperty("LineageConfiguration")
@@ -1,12 +1,15 @@
package io.github.hectorvent.floci.services.glue.model; package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
@RegisterForReflection @RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Database { public class Database {
@JsonProperty("Name") @JsonProperty("Name")
private String name; private String name;
@@ -18,6 +21,7 @@ public class Database {
private Map<String, String> parameters; private Map<String, String> parameters;
@JsonProperty("CreateTime") @JsonProperty("CreateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createTime; private Instant createTime;
@JsonProperty("CatalogId") @JsonProperty("CatalogId")
private String catalogId; private String catalogId;
@@ -3,6 +3,7 @@ package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant; import java.time.Instant;
@@ -22,10 +23,12 @@ public class GluePolicy {
@JsonProperty("CreateTime") @JsonProperty("CreateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createTime; private Instant createTime;
@JsonProperty("UpdateTime") @JsonProperty("UpdateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant updateTime; private Instant updateTime;
public String getPolicyInJson() { return policyInJson; } public String getPolicyInJson() { return policyInJson; }
@@ -0,0 +1,29 @@
package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.Instant;
/**
* Writes a Glue timestamp as AWS does: epoch seconds to the millisecond, in the shortest
* form ({@code 1792964720.27}, not {@code 1792964720.270000000}). The AWS SDK for
* JavaScript v3 keeps any number whose text differs from its shortest form as a
* BigDecimal, and then refuses it as a timestamp.
*/
@RegisterForReflection
public class GlueTimestampSerializer extends JsonSerializer<Instant> {
@Override
public void serialize(Instant value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
gen.writeNumber(epochSeconds(value));
}
static String epochSeconds(Instant value) {
return BigDecimal.valueOf(value.toEpochMilli(), 3).stripTrailingZeros().toPlainString();
}
}
@@ -1,7 +1,9 @@
package io.github.hectorvent.floci.services.glue.model; package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
@@ -9,6 +11,7 @@ import java.time.Instant;
import java.util.Map; import java.util.Map;
@RegisterForReflection @RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Job { public class Job {
@JsonProperty("AllocatedCapacity") @JsonProperty("AllocatedCapacity")
private Integer allocatedCapacity; private Integer allocatedCapacity;
@@ -24,6 +27,7 @@ public class Job {
@JsonProperty("CreatedOn") @JsonProperty("CreatedOn")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createdOn; private Instant createdOn;
@JsonProperty("DefaultArguments") @JsonProperty("DefaultArguments")
@@ -49,6 +53,7 @@ public class Job {
@JsonProperty("LastModifiedOn") @JsonProperty("LastModifiedOn")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastModifiedOn; private Instant lastModifiedOn;
@JsonProperty("LogUri") @JsonProperty("LogUri")
@@ -1,11 +1,14 @@
package io.github.hectorvent.floci.services.glue.model; package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant; import java.time.Instant;
@RegisterForReflection @RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LastCrawlInfo { public class LastCrawlInfo {
@JsonProperty("ErrorMessage") @JsonProperty("ErrorMessage")
private String errorMessage; private String errorMessage;
@@ -21,6 +24,7 @@ public class LastCrawlInfo {
@JsonProperty("StartTime") @JsonProperty("StartTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant startTime; private Instant startTime;
@JsonProperty("Status") @JsonProperty("Status")
@@ -1,13 +1,16 @@
package io.github.hectorvent.floci.services.glue.model; package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@RegisterForReflection @RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Partition { public class Partition {
@JsonProperty("Values") @JsonProperty("Values")
private List<String> values; private List<String> values;
@@ -17,9 +20,11 @@ public class Partition {
private String tableName; private String tableName;
@JsonProperty("CreationTime") @JsonProperty("CreationTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant creationTime; private Instant creationTime;
@JsonProperty("LastAccessTime") @JsonProperty("LastAccessTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastAccessTime; private Instant lastAccessTime;
@JsonProperty("StorageDescriptor") @JsonProperty("StorageDescriptor")
private StorageDescriptor storageDescriptor; private StorageDescriptor storageDescriptor;
@@ -1,13 +1,16 @@
package io.github.hectorvent.floci.services.glue.model; package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@RegisterForReflection @RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Table { public class Table {
@JsonProperty("Name") @JsonProperty("Name")
private String name; private String name;
@@ -19,12 +22,15 @@ public class Table {
private String owner; private String owner;
@JsonProperty("CreateTime") @JsonProperty("CreateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createTime; private Instant createTime;
@JsonProperty("UpdateTime") @JsonProperty("UpdateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant updateTime; private Instant updateTime;
@JsonProperty("LastAccessTime") @JsonProperty("LastAccessTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastAccessTime; private Instant lastAccessTime;
@JsonProperty("PartitionKeys") @JsonProperty("PartitionKeys")
private List<Column> partitionKeys; private List<Column> partitionKeys;
@@ -1,13 +1,16 @@
package io.github.hectorvent.floci.services.glue.model; package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection; import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
@RegisterForReflection @RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserDefinedFunction { public class UserDefinedFunction {
@JsonProperty("FunctionName") @JsonProperty("FunctionName")
private String functionName; private String functionName;
@@ -23,6 +26,7 @@ public class UserDefinedFunction {
private String ownerType; private String ownerType;
@JsonProperty("CreateTime") @JsonProperty("CreateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER) @JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createTime; private Instant createTime;
@JsonProperty("ResourceUris") @JsonProperty("ResourceUris")
private List<ResourceUri> resourceUris; private List<ResourceUri> resourceUris;
@@ -0,0 +1,70 @@
package io.github.hectorvent.floci.services.acm;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.notNullValue;
@QuarkusTest
class AcmExportableRequestTest {
private static final String ACM_CONTENT_TYPE = "application/x-amz-json-1.1";
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
@Test
void requestCertificateKeepsTheExportOption() {
String exportable = request("{\"DomainName\":\"exportable.example.com\",\"Options\":{\"Export\":\"ENABLED\"}}");
call("DescribeCertificate", "{\"CertificateArn\":\"" + exportable + "\"}")
.statusCode(200)
.body("Certificate.Options.Export", equalTo("ENABLED"))
.body("Certificate.Options.CertificateTransparencyLoggingPreference", equalTo("ENABLED"));
call("ExportCertificate", "{\"CertificateArn\":\"" + exportable + "\",\"Passphrase\":\"c2VjcmV0\"}")
.statusCode(200)
.body("PrivateKey", notNullValue());
}
@Test
void requestCertificateWithoutExportIsNotExportable() {
String plain = request("{\"DomainName\":\"plain.example.com\",\"Options\":{\"CertificateTransparencyLoggingPreference\":\"DISABLED\"}}");
call("DescribeCertificate", "{\"CertificateArn\":\"" + plain + "\"}")
.statusCode(200)
.body("Certificate.Options.Export", equalTo("DISABLED"))
.body("Certificate.Options.CertificateTransparencyLoggingPreference", equalTo("DISABLED"));
call("ExportCertificate", "{\"CertificateArn\":\"" + plain + "\",\"Passphrase\":\"c2VjcmV0\"}")
.statusCode(400)
.body("__type", equalTo("ValidationException"));
}
@Test
void requestCertificateRejectsAnUnknownExportValue() {
call("RequestCertificate", "{\"DomainName\":\"bad.example.com\",\"Options\":{\"Export\":\"MAYBE\"}}")
.statusCode(400)
.body("__type", equalTo("ValidationException"));
}
private String request(String body) {
return call("RequestCertificate", body).statusCode(200).extract().jsonPath().getString("CertificateArn");
}
private io.restassured.response.ValidatableResponse call(String action, String body) {
return given()
.header("X-Amz-Target", "CertificateManager." + action)
.contentType(ACM_CONTENT_TYPE)
.body(body)
.when()
.post("/")
.then();
}
}
@@ -0,0 +1,67 @@
package io.github.hectorvent.floci.services.glue;
import io.github.hectorvent.floci.testing.RestAssuredJsonUtils;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasKey;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.notNullValue;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* AWS leaves unset fields out of Glue responses and reports times in milliseconds.
* A null or nanosecond timestamp instead breaks the AWS SDK for JavaScript v3,
* which refuses to read either.
*/
@QuarkusTest
class GlueOmitsNullFieldsIntegrationTest {
private static final String CONTENT_TYPE = "application/x-amz-json-1.1";
private static final String DATABASE = "nulls_" + UUID.randomUUID().toString().substring(0, 8);
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
private static io.restassured.response.ValidatableResponse glue(String action, String body) {
return given().contentType(CONTENT_TYPE)
.header("X-Amz-Target", "AWSGlue." + action)
.body(body)
.when().post("/")
.then().statusCode(200);
}
@Test
void aNewTableHasOnlyAMillisecondCreateTime() {
glue("CreateDatabase", "{\"DatabaseInput\": {\"Name\": \"" + DATABASE + "\"}}");
glue("CreateTable", """
{
"DatabaseName": "%s",
"TableInput": {
"Name": "orders",
"StorageDescriptor": {"Columns": [{"Name": "id", "Type": "int"}], "Location": "s3://data/orders/"}
}
}
""".formatted(DATABASE));
String tables = glue("GetTables", "{\"DatabaseName\": \"" + DATABASE + "\"}")
.body("TableList[0].Name", equalTo("orders"))
.body("TableList[0].CreateTime", notNullValue())
.body("TableList[0]", not(hasKey("UpdateTime")))
.body("TableList[0]", not(hasKey("LastAccessTime")))
.extract().asString();
// Milliseconds in their shortest form, as AWS sends them: no trailing zeros.
assertTrue(tables.matches("(?s).*\"CreateTime\":\\d+(\\.\\d{0,2}[1-9])?[,}].*"), tables);
glue("GetDatabase", "{\"Name\": \"" + DATABASE + "\"}")
.body("Database", not(hasKey("Description")))
.body("Database.CreateTime", notNullValue());
}
}
@@ -0,0 +1,17 @@
package io.github.hectorvent.floci.services.glue.model;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
class GlueTimestampSerializerTest {
@Test
void writesMillisecondsInTheirShortestForm() {
assertEquals("1792964720.273", GlueTimestampSerializer.epochSeconds(Instant.ofEpochSecond(1792964720, 273_456_789)));
assertEquals("1792964720.27", GlueTimestampSerializer.epochSeconds(Instant.ofEpochSecond(1792964720, 270_000_000)));
assertEquals("1792964720", GlueTimestampSerializer.epochSeconds(Instant.ofEpochSecond(1792964720)));
}
}