Merge pull request 'fix(glue): leave unset fields out of catalog responses' (#15) from fix/glue-omit-null-fields into main

Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
2026-10-25 23:44:08 +00:00
13 changed files with 156 additions and 0 deletions
@@ -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,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)));
}
}