Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,52 @@
# data-sync-api-sdk-java
Repository to hold common code used across data sync deltas

Repository to hold common code used across data sync deltas.

## Major Changes

- **Upgraded to Spring Boot 4** and **Jackson 3** (tools.jackson).
- **Java 21** is now required.
- All Jackson 2 (com.fasterxml.jackson) dependencies are excluded where possible, except where required for legacy compatibility (see below).
- Custom serializers/deserializers for MongoDB date fields, compatible with Jackson 3.

## Dependency Notes

- The SDK uses Jackson 3 (tools.jackson) for all serialization/deserialization.
- Some dependencies (e.g., `private-api-sdk-java`) still require Jackson 2 at runtime. Both Jackson 2 and 3 can coexist due to different package namespaces.
- Exclusions are applied to avoid accidental inclusion of Jackson 2 in most cases, but not all can be removed until all dependencies migrate to Jackson 3.

## LocalDateDeserializer (Jackson 3.x compatible)

This SDK includes a custom `LocalDateDeserializer` for MongoDB date fields, compatible with Jackson 3.x. It supports both ISO date strings and MongoDB's `$numberLong` format, and throws a `BadRequestException` for invalid or missing date values.

**Key features:**
- Handles both `{ "$date": "yyyy-MM-dd'T'HH:mm:ss'Z'" }` and `{ "$date": { "$numberLong": "..." } }` formats
- Uses Jackson 3.x methods (`isString()`, `stringValue()`)
- Robust error handling with clear exceptions

**Example usage:**

```json
{"date": {"$date": "2023-01-09T00:00:00Z"}}
{"date": {"$date": {"$numberLong": "-1431388800000"}}}
```

See [`src/main/java/uk/gov/companieshouse/api/serialization/LocalDateDeserializer.java`](src/main/java/uk/gov/companieshouse/api/serialization/LocalDateDeserializer.java) for implementation details.

## Build and Test

To build and run tests:

```sh
mvn clean test
```

## Compatibility

- Requires Java 21+
- Requires Maven 3.9+
- Designed for Spring Boot 4 and Jackson 3.x

## License

See [LICENSE](LICENSE).
63 changes: 62 additions & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<properties>
<java.version>21</java.version>
<maven-build-helper-plugin.version>3.2.0</maven-build-helper-plugin.version>
<spring-boot-dependencies.version>3.5.13</spring-boot-dependencies.version>
<spring-boot-dependencies.version>4.0.4</spring-boot-dependencies.version>

<!-- tests -->
<maven-surefire-plugin.version>3.5.3</maven-surefire-plugin.version>
Expand Down Expand Up @@ -61,6 +61,7 @@
<log4j-api.version>2.25.4</log4j-api.version>
<guava.version>32.0.1-android</guava.version>
<grpc-context.version>1.80.0</grpc-context.version>
<jackson-core.version>3.1.2</jackson-core.version>
</properties>

<dependencyManagement>
Expand Down Expand Up @@ -143,6 +144,12 @@
<version>${grpc-context.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson-core.version}</version>
<scope>compile</scope>
</dependency>
</dependencies>
</dependencyManagement>

Expand Down Expand Up @@ -181,8 +188,22 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--
Jackson 2.x dependencies are intentionally NOT excluded from private-api-sdk-java.
Although this service uses Jackson 3 (tools.jackson) via Spring Boot 4, private-api-sdk-java
was compiled against Jackson 2 (com.fasterxml.jackson) and its bytecode references those
classes directly at runtime. Excluding them causes ClassNotFoundException at runtime.
Both Jackson 2 and Jackson 3 can coexist on the classpath because they use different
package namespaces (com.fasterxml.jackson vs tools.jackson).
Remove this comment and revisit exclusions once private-api-sdk-java has been migrated
to Jackson 3.
-->
<dependency>
<groupId>uk.gov.companieshouse</groupId>
<artifactId>private-api-sdk-java</artifactId>
Expand Down Expand Up @@ -214,23 +235,63 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>uk.gov.companieshouse</groupId>
<artifactId>api-helper-java</artifactId>
<version>${api-helper-java-library.version}</version>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</exclusion>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-api</artifactId>
<version>${docker-java-api.version}</version>
<scope>compile</scope>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.ReadingConverter;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectMapper;

import uk.gov.companieshouse.api.exception.InternalServiceException;

@ReadingConverter
public class ReadConverter<T> implements Converter<Document, T> {
Expand All @@ -22,10 +20,6 @@ public ReadConverter(ObjectMapper objectMapper, Class<T> objectClass) {

@Override
public T convert(Document source) {
try {
return this.objectMapper.readValue(source.toJson(), objectClass);
} catch (JsonProcessingException e) {
throw new InternalServiceException("failed to read and convert Document to JSON", e);
}
return this.objectMapper.readValue(source.toJson(), objectClass);
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package uk.gov.companieshouse.api.converter;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mongodb.BasicDBObject;
import tools.jackson.databind.ObjectMapper;

import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.WritingConverter;

Expand All @@ -14,17 +14,15 @@ public class WriteConverter<S> implements Converter<S, BasicDBObject> {
public WriteConverter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}

/**
* Write convertor.
*
* @param source object.
* @return BSON object to be saved as part of Document.
*/
@Override
public BasicDBObject convert(S source) {
try {
return BasicDBObject.parse(objectMapper.writeValueAsString(source));
} catch (JsonProcessingException e) {
throw new IllegalArgumentException(e);
}
return BasicDBObject.parse(objectMapper.writeValueAsString(source));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@ public void setKind(TransactionKind kind) {

@Override
public boolean equals(Object obj) {
if (!(obj instanceof TransactionKindResult)) {
if (!(obj instanceof TransactionKindResult cmp)) {
return false;
}
TransactionKindResult cmp = (TransactionKindResult) obj;

final boolean sameKind = (this.kind == null && cmp.kind == null) ||
(this.kind != null && this.kind.equals(cmp.kind));
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,49 @@
package uk.gov.companieshouse.api.serialization;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.ValueDeserializer;
import tools.jackson.databind.JsonNode;
import java.time.ZoneOffset;
import uk.gov.companieshouse.api.exception.BadRequestException;

import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class LocalDateDeserializer extends JsonDeserializer<LocalDate> {
public class LocalDateDeserializer extends ValueDeserializer<LocalDate> {
@Override
public LocalDate deserialize(JsonParser jsonParser, DeserializationContext
deserializationContext) {
public LocalDate deserialize(JsonParser jsonParser,
DeserializationContext deserializationContext) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
JsonNode dateNode;
try {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'");
JsonNode jsonNode = jsonParser.readValueAsTree();
JsonNode dateNode = jsonNode.get("$date");
dateNode = ((JsonNode) jsonParser.readValueAsTree()).get("$date");
} catch (Exception e) {
throw new BadRequestException("Deserialization failed.", e);
}

return dateNode.textValue() != null ?
LocalDate.parse(dateNode.textValue(), dateTimeFormatter) :
Instant.ofEpochMilli(dateNode.get("$numberLong").asLong())
.atZone(ZoneOffset.UTC).toLocalDate();
if (dateNode == null || dateNode.isNull()) {
throw new BadRequestException("$date field is missing or null");
}

} catch (Exception exception) {
throw new BadRequestException("Deserialization failed.", exception);
if (dateNode.isString()) {
try {
return LocalDate.parse(dateNode.stringValue(), fmt);
} catch (Exception e) {
throw new BadRequestException("Deserialization failed.", e);
}
}
}

}
if (dateNode.isObject() && dateNode.has("$numberLong")) {
try {
return Instant.ofEpochMilli(Long.parseLong(dateNode.get("$numberLong").stringValue()))
.atZone(ZoneOffset.UTC)
.toLocalDate();
} catch (Exception e) {
throw new BadRequestException("Deserialization failed.", e);
}
}

throw new BadRequestException("Unrecognized $date format");
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
package uk.gov.companieshouse.api.serialization;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import tools.jackson.databind.ValueSerializer;
import tools.jackson.databind.SerializationContext;
import tools.jackson.core.JsonGenerator;

import java.io.IOException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public class LocalDateSerializer extends JsonSerializer<LocalDate> {

public class LocalDateSerializer extends ValueSerializer<LocalDate> {
@Override
public void serialize(LocalDate localDate, JsonGenerator jsonGenerator,
SerializerProvider serializerProvider) throws IOException {
if (localDate == null) {
jsonGenerator.writeNull();
public void serialize(LocalDate value, JsonGenerator gen, SerializationContext ctxt) {
if (value == null) {
gen.writeNull();
} else {
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
String format = localDate.atStartOfDay().format(dtf);
jsonGenerator.writeRawValue("ISODate(\"" + format + "\")");
String format = value.atStartOfDay().format(dtf);
gen.writeRawValue("ISODate(\"" + format + "\")");
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package uk.gov.companieshouse.api.converter;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import tools.jackson.core.JacksonException;
import org.bson.Document;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import uk.gov.companieshouse.api.exception.InternalServiceException;
import tools.jackson.databind.ObjectMapper;
import uk.gov.companieshouse.api.psc.Statement;
import uk.gov.companieshouse.api.utils.TestHelper;

Expand All @@ -35,19 +34,19 @@ void correctlyConvertsDocumentToStatementObject(){
}

@Test
void testConvertThrowsInternalServiceExceptionOnJsonError() throws Exception {
void testConvertThrowsInternalServiceExceptionOnJsonError() {
// GIVEN
ObjectMapper objectMapper = mock(ObjectMapper.class);
Document source = new Document();
when(objectMapper.readValue(anyString(), ArgumentMatchers.<Class<Object>>any()))
.thenThrow(new JsonProcessingException("Error") {});
.thenThrow(new JacksonException("Error") {});

// WHEN
ReadConverter<Statement> converter = new ReadConverter<>(objectMapper, Statement.class);

// THEN
Exception ex = Assertions.assertThrows(InternalServiceException.class, () -> converter.convert(source));
Assertions.assertEquals("failed to read and convert Document to JSON", ex.getMessage());
Exception ex = Assertions.assertThrows(JacksonException.class, () -> converter.convert(source));
Assertions.assertEquals("Error", ex.getMessage());

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.mongodb.BasicDBObject;

import tools.jackson.databind.ObjectMapper;
import uk.gov.companieshouse.api.psc.Statement;
import uk.gov.companieshouse.api.psc.Statement.KindEnum;
import uk.gov.companieshouse.api.utils.TestHelper;
Expand Down
Loading