Compare commits

..

2 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
17 changed files with 165 additions and 149 deletions
@@ -29,10 +29,6 @@ public class EfsException extends AwsException {
return new EfsException(Status.NOT_FOUND, "MountTargetNotFound", "Mount target " + mountTargetId + " does not exist.");
}
public static EfsException subnetNotFound(String subnetId) {
return new EfsException(Status.BAD_REQUEST, "SubnetNotFound", "The subnet ID '" + subnetId + "' does not exist");
}
public static EfsException mountTargetConflict(String message) {
return new EfsException(Status.CONFLICT, "MountTargetConflict", message);
}
@@ -2,10 +2,7 @@ package io.github.hectorvent.floci.services.efs;
import io.github.hectorvent.floci.core.storage.StorageBackend;
import io.github.hectorvent.floci.core.storage.StorageFactory;
import io.github.hectorvent.floci.core.common.AwsException;
import io.github.hectorvent.floci.core.common.Resettable;
import io.github.hectorvent.floci.services.ec2.Ec2Service;
import io.github.hectorvent.floci.services.ec2.model.Subnet;
import io.github.hectorvent.floci.services.efs.model.*;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@@ -36,16 +33,13 @@ public class EfsService implements Resettable {
private final ConcurrentHashMap<String, Object> syncLocks = new ConcurrentHashMap<>();
private final RegionResolver regionResolver;
private final Ec2Service ec2Service;
private Object lockFor(String key) {
return syncLocks.computeIfAbsent(key, k -> new Object());
}
@Inject
public EfsService(StorageFactory storageFactory, RegionResolver regionResolver, Ec2Service ec2Service) {
public EfsService(StorageFactory storageFactory, RegionResolver regionResolver) {
this.regionResolver = regionResolver;
this.ec2Service = ec2Service;
this.fileSystemStore = storageFactory.create("efs", "efs-filesystems.json",
new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, FileSystem>>() {});
this.mountTargetStore = storageFactory.create("efs", "efs-mounttargets.json",
@@ -367,24 +361,14 @@ public class EfsService implements Resettable {
public MountTarget createMountTarget(CreateMountTargetRequest request, String region) {
synchronized (lockFor(regionKey(region, request.getFileSystemId()))) {
FileSystem fs = getFileSystem(region, request.getFileSystemId());
Subnet subnet = requireSubnet(region, request.getSubnetId());
List<MountTarget> regionMountTargets = mountTargetStore.scan(k -> k.startsWith(region + "::"));
for (MountTarget existing : regionMountTargets) {
for (MountTarget existing : mountTargetStore.scan(k -> k.startsWith(region + "::"))) {
if (existing.getFileSystemId().equals(request.getFileSystemId())) {
if (existing.getSubnetId().equals(request.getSubnetId())) {
throw EfsException.mountTargetConflict("Mount target already exists in this subnet");
}
// AWS allows one mount target per Availability Zone, all in one VPC.
if (subnet.getAvailabilityZone().equals(existing.getAvailabilityZoneName())) {
throw EfsException.mountTargetConflict("Mount target already exists in this AZ");
}
if (!subnet.getVpcId().equals(existing.getVpcId())) {
throw EfsException.mountTargetConflict("Mount targets of a file system must be in the same VPC");
}
}
}
long inSubnet = regionMountTargets.stream().filter(existing -> request.getSubnetId().equals(existing.getSubnetId())).count();
String mtId = "fsmt-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17);
@@ -392,11 +376,11 @@ public class EfsService implements Resettable {
mt.setMountTargetId(mtId);
mt.setFileSystemId(request.getFileSystemId());
mt.setSubnetId(request.getSubnetId());
mt.setIpAddress(request.getIpAddress() != null ? request.getIpAddress() : hostAddress(subnet.getCidrBlock(), inSubnet));
mt.setIpAddress(request.getIpAddress() != null ? request.getIpAddress() : "10.0.0.10");
mt.setLifeCycleState(LifeCycleState.available);
mt.setVpcId(subnet.getVpcId());
mt.setAvailabilityZoneName(subnet.getAvailabilityZone());
mt.setAvailabilityZoneId(generateAzId(subnet.getAvailabilityZone()));
mt.setVpcId("vpc-12345678");
mt.setAvailabilityZoneName(region + "a");
mt.setAvailabilityZoneId(generateAzId(region + "a"));
mt.setNetworkInterfaceId("eni-" + UUID.randomUUID().toString().replace("-", "").substring(0, 17));
if (request.getSecurityGroups() != null) {
mt.setSecurityGroups(new ArrayList<>(request.getSecurityGroups()));
@@ -629,32 +613,6 @@ public class EfsService implements Resettable {
return region + "::" + id;
}
private Subnet requireSubnet(String region, String subnetId) {
if (subnetId == null || subnetId.isBlank()) {
throw EfsException.badRequest("SubnetId is required.");
}
try {
return ec2Service.requireSubnet(region, subnetId);
} catch (AwsException e) {
throw EfsException.subnetNotFound(subnetId);
}
}
/**
* An address inside the subnet for its n-th mount target, skipping the first four
* addresses AWS reserves in every subnet.
*/
static String hostAddress(String cidrBlock, long index) {
String[] parts = cidrBlock.split("/");
String[] octets = parts[0].split("\\.");
long base = 0;
for (String octet : octets) {
base = (base << 8) | Long.parseLong(octet);
}
long address = base + 4 + index;
return ((address >> 24) & 0xff) + "." + ((address >> 16) & 0xff) + "." + ((address >> 8) & 0xff) + "." + (address & 0xff);
}
private String generateAzId(String azName) {
if (azName == null || azName.length() < 2) return azName + "-id";
String region = azName.substring(0, azName.length() - 1);
@@ -243,6 +243,9 @@ public class GlueService {
}
database.setName(databaseName);
database.setCatalogId(regionResolver.getAccountId());
if (database.getCreateTime() == null) {
database.setCreateTime(Instant.now());
}
databaseStore.put(databaseName, database);
if (tags != null && !tags.isEmpty()) {
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.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
@@ -47,10 +48,12 @@ public class Connection {
@JsonProperty("CreationTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant creationTime;
@JsonProperty("LastUpdatedTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastUpdatedTime;
@JsonProperty("LastUpdatedBy")
@@ -64,6 +67,7 @@ public class Connection {
@JsonProperty("LastConnectionValidationTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastConnectionValidationTime;
@JsonProperty("AuthenticationConfiguration")
@@ -1,12 +1,15 @@
package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.List;
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Crawler {
@JsonProperty("Classifiers")
private List<String> classifiers;
@@ -22,6 +25,7 @@ public class Crawler {
@JsonProperty("CreationTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant creationTime;
@JsonProperty("DatabaseName")
@@ -38,6 +42,7 @@ public class Crawler {
@JsonProperty("LastUpdated")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastUpdated;
@JsonProperty("LineageConfiguration")
@@ -1,12 +1,15 @@
package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.Map;
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Database {
@JsonProperty("Name")
private String name;
@@ -18,6 +21,7 @@ public class Database {
private Map<String, String> parameters;
@JsonProperty("CreateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createTime;
@JsonProperty("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.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
@@ -22,10 +23,12 @@ public class GluePolicy {
@JsonProperty("CreateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createTime;
@JsonProperty("UpdateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant updateTime;
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;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.JsonNode;
import io.quarkus.runtime.annotations.RegisterForReflection;
@@ -9,6 +11,7 @@ import java.time.Instant;
import java.util.Map;
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Job {
@JsonProperty("AllocatedCapacity")
private Integer allocatedCapacity;
@@ -24,6 +27,7 @@ public class Job {
@JsonProperty("CreatedOn")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createdOn;
@JsonProperty("DefaultArguments")
@@ -49,6 +53,7 @@ public class Job {
@JsonProperty("LastModifiedOn")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastModifiedOn;
@JsonProperty("LogUri")
@@ -1,11 +1,14 @@
package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class LastCrawlInfo {
@JsonProperty("ErrorMessage")
private String errorMessage;
@@ -21,6 +24,7 @@ public class LastCrawlInfo {
@JsonProperty("StartTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant startTime;
@JsonProperty("Status")
@@ -1,13 +1,16 @@
package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Partition {
@JsonProperty("Values")
private List<String> values;
@@ -17,9 +20,11 @@ public class Partition {
private String tableName;
@JsonProperty("CreationTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant creationTime;
@JsonProperty("LastAccessTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastAccessTime;
@JsonProperty("StorageDescriptor")
private StorageDescriptor storageDescriptor;
@@ -1,13 +1,16 @@
package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.List;
import java.util.Map;
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Table {
@JsonProperty("Name")
private String name;
@@ -19,12 +22,15 @@ public class Table {
private String owner;
@JsonProperty("CreateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createTime;
@JsonProperty("UpdateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant updateTime;
@JsonProperty("LastAccessTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant lastAccessTime;
@JsonProperty("PartitionKeys")
private List<Column> partitionKeys;
@@ -1,13 +1,16 @@
package io.github.hectorvent.floci.services.glue.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.quarkus.runtime.annotations.RegisterForReflection;
import java.time.Instant;
import java.util.List;
@RegisterForReflection
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserDefinedFunction {
@JsonProperty("FunctionName")
private String functionName;
@@ -23,6 +26,7 @@ public class UserDefinedFunction {
private String ownerType;
@JsonProperty("CreateTime")
@JsonFormat(shape = JsonFormat.Shape.NUMBER)
@JsonSerialize(using = GlueTimestampSerializer.class)
private Instant createTime;
@JsonProperty("ResourceUris")
private List<ResourceUri> resourceUris;
@@ -90,7 +90,7 @@ class EfsIntegrationTest {
.body("""
{
"FileSystemId": "%s",
"SubnetId": "subnet-default-us-east-1-a",
"SubnetId": "subnet-12345",
"SecurityGroups": ["sg-11111"]
}
""".formatted(fileSystemId))
@@ -100,10 +100,7 @@ class EfsIntegrationTest {
.statusCode(200)
.body("MountTargetId", startsWith("fsmt-"))
.body("FileSystemId", equalTo(fileSystemId))
.body("SubnetId", equalTo("subnet-default-us-east-1-a"))
.body("VpcId", equalTo("vpc-default-us-east-1"))
.body("AvailabilityZoneName", equalTo("us-east-1a"))
.body("IpAddress", equalTo("172.31.0.4"))
.body("SubnetId", equalTo("subnet-12345"))
.extract().jsonPath().getString("MountTargetId");
}
@@ -1,91 +0,0 @@
package io.github.hectorvent.floci.services.efs;
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.junit.jupiter.api.Assertions.assertEquals;
/** Mount targets take their VPC, zone and address from the EC2 subnet they are created in. */
@QuarkusTest
class EfsMountTargetSubnetTest {
@BeforeAll
static void configureRestAssured() {
RestAssuredJsonUtils.configureAwsContentTypes();
}
private String createFileSystem(String token) {
return given()
.contentType("application/json")
.body("{\"CreationToken\": \"" + token + "\"}")
.when()
.post("/2015-02-01/file-systems")
.then()
.statusCode(201)
.extract().jsonPath().getString("FileSystemId");
}
private io.restassured.response.ValidatableResponse createMountTarget(String fileSystemId, String subnetId) {
return given()
.contentType("application/json")
.body("{\"FileSystemId\": \"" + fileSystemId + "\", \"SubnetId\": \"" + subnetId + "\"}")
.when()
.post("/2015-02-01/mount-targets")
.then();
}
@Test
void usesTheZoneAndVpcOfEachSubnet() {
String fileSystemId = createFileSystem("subnet-zones");
createMountTarget(fileSystemId, "subnet-default-us-east-1-b")
.statusCode(200)
.body("VpcId", equalTo("vpc-default-us-east-1"))
.body("AvailabilityZoneName", equalTo("us-east-1b"))
.body("IpAddress", equalTo("172.31.16.4"));
createMountTarget(fileSystemId, "subnet-default-us-east-1-c")
.statusCode(200)
.body("AvailabilityZoneName", equalTo("us-east-1c"));
}
@Test
void refusesASubnetThatDoesNotExist() {
String fileSystemId = createFileSystem("subnet-missing");
createMountTarget(fileSystemId, "subnet-nope")
.statusCode(400)
.body("__type", equalTo("SubnetNotFound"));
}
@Test
void allowsOneMountTargetPerZone() {
String fileSystemId = createFileSystem("subnet-one-per-zone");
String vpcId = "vpc-default-us-east-1";
String secondSubnet = given()
.formParam("Action", "CreateSubnet")
.formParam("VpcId", vpcId)
.formParam("CidrBlock", "172.31.200.0/24")
.formParam("AvailabilityZone", "us-east-1a")
.header("Authorization", "AWS4-HMAC-SHA256 Credential=test/20260101/us-east-1/ec2/aws4_request")
.when()
.post("/")
.then()
.statusCode(200)
.extract().path("CreateSubnetResponse.subnet.subnetId");
createMountTarget(fileSystemId, "subnet-default-us-east-1-a").statusCode(200);
createMountTarget(fileSystemId, secondSubnet)
.statusCode(409)
.body("__type", equalTo("MountTargetConflict"));
}
@Test
void picksAddressesAfterTheFourAwsReserves() {
assertEquals("10.0.1.4", EfsService.hostAddress("10.0.1.0/24", 0));
assertEquals("10.0.2.1", EfsService.hostAddress("10.0.1.0/24", 253));
}
}
@@ -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)));
}
}