diff --git a/src/main/java/dev/sorn/fmp4j/json/FmpJsonModule.java b/src/main/java/dev/sorn/fmp4j/json/FmpJsonModule.java index 94e799bc..1383f91b 100644 --- a/src/main/java/dev/sorn/fmp4j/json/FmpJsonModule.java +++ b/src/main/java/dev/sorn/fmp4j/json/FmpJsonModule.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule; import dev.sorn.fmp4j.types.FmpIsin; +import dev.sorn.fmp4j.types.FmpSymbol; public final class FmpJsonModule extends SimpleModule { @@ -10,5 +11,6 @@ public final class FmpJsonModule extends SimpleModule { public FmpJsonModule() { super(FMP_JSON_MODULE_NAME); this.addDeserializer(FmpIsin.class, new FmpJsonBlankAsNullStringDeserializer<>(FmpIsin::isin)); + this.addDeserializer(FmpSymbol.class, new FmpSymbolDeserializer()); } } diff --git a/src/main/java/dev/sorn/fmp4j/json/FmpSymbolDeserializer.java b/src/main/java/dev/sorn/fmp4j/json/FmpSymbolDeserializer.java new file mode 100644 index 00000000..bf49edcb --- /dev/null +++ b/src/main/java/dev/sorn/fmp4j/json/FmpSymbolDeserializer.java @@ -0,0 +1,26 @@ +package dev.sorn.fmp4j.json; + +import static dev.sorn.fmp4j.types.FmpSymbol.symbol; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import dev.sorn.fmp4j.exceptions.FmpInvalidSymbolException; +import dev.sorn.fmp4j.types.FmpSymbol; +import java.io.IOException; + +public class FmpSymbolDeserializer extends JsonDeserializer { + @Override + public FmpSymbol deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + String value = p.getText(); + if (value == null || value.trim().isEmpty()) { + return null; // or skip + } + try { + return symbol(value); + } catch (FmpInvalidSymbolException e) { + System.err.printf("Invalid symbol skipped: %s%n", value); + return null; + } + } +} diff --git a/src/main/java/dev/sorn/fmp4j/services/FmpService.java b/src/main/java/dev/sorn/fmp4j/services/FmpService.java index cf1a85fe..1c520a12 100644 --- a/src/main/java/dev/sorn/fmp4j/services/FmpService.java +++ b/src/main/java/dev/sorn/fmp4j/services/FmpService.java @@ -31,6 +31,10 @@ protected final URI url() { protected abstract Set optionalParams(); + protected R filter(R r) { + return r; + } + public final void param(String key, Object value) { if (!requiredParams().contains(key) && !optionalParams().contains(key)) { throw new FmpServiceException("'%s' is not a recognized query param for endpoint [%s]", key, url()); @@ -49,6 +53,6 @@ public final R download() { throw new FmpServiceException("'%s' is a required query param for endpoint [%s]", req, url()); } } - return http.get(typeRef, url(), headers(), params); + return filter(http.get(typeRef, url(), headers(), params)); } } diff --git a/src/main/java/dev/sorn/fmp4j/services/FmpStockListService.java b/src/main/java/dev/sorn/fmp4j/services/FmpStockListService.java index 0bbc49cb..a6e8c822 100644 --- a/src/main/java/dev/sorn/fmp4j/services/FmpStockListService.java +++ b/src/main/java/dev/sorn/fmp4j/services/FmpStockListService.java @@ -1,6 +1,7 @@ package dev.sorn.fmp4j.services; import static dev.sorn.fmp4j.json.FmpJsonUtils.typeRef; +import static java.util.Arrays.stream; import static java.util.Collections.emptySet; import dev.sorn.fmp4j.cfg.FmpConfig; @@ -27,4 +28,9 @@ protected Set requiredParams() { protected Set optionalParams() { return emptySet(); } + + @Override + protected FmpStock[] filter(FmpStock[] stocks) { + return stream(stocks).filter(s -> s.symbol() != null).toArray(FmpStock[]::new); + } } diff --git a/src/test/java/dev/sorn/fmp4j/json/FmpSymbolDeserializerTest.java b/src/test/java/dev/sorn/fmp4j/json/FmpSymbolDeserializerTest.java new file mode 100644 index 00000000..51f6cc8e --- /dev/null +++ b/src/test/java/dev/sorn/fmp4j/json/FmpSymbolDeserializerTest.java @@ -0,0 +1,70 @@ +package dev.sorn.fmp4j.json; + +import static dev.sorn.fmp4j.types.FmpSymbol.symbol; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import java.io.IOException; +import org.junit.jupiter.api.Test; + +class FmpSymbolDeserializerTest { + private final JsonParser p = mock(JsonParser.class); + private final DeserializationContext ctx = mock(DeserializationContext.class); + private final FmpSymbolDeserializer deserializer = new FmpSymbolDeserializer(); + + @Test + void deserialize_null_symbol_returns_null() throws IOException { + // given + var nullStr = (String) null; + + // when + when(p.getText()).thenReturn(nullStr); + var result = deserializer.deserialize(p, ctx); + + // then + assertNull(result); + } + + @Test + void deserialize_blank_symbol_returns_null() throws IOException { + // given + var blank = ""; + + // when + when(p.getText()).thenReturn(blank); + var result = deserializer.deserialize(p, ctx); + + // then + assertNull(result); + } + + @Test + void deserialize_invalid_symbol_returns_null() throws IOException { + // given + var invalid = "$INVALID"; + + // when + when(p.getText()).thenReturn(invalid); + var result = deserializer.deserialize(p, ctx); + + // then + assertNull(result); + } + + @Test + void deserialize_valid_symbol_returns_symbol() throws IOException { + // given + var symbol = "AAPL"; + + // when + when(p.getText()).thenReturn(symbol); + var result = deserializer.deserialize(p, ctx); + + // then + assertEquals(symbol("AAPL"), result); + } +} diff --git a/src/test/java/dev/sorn/fmp4j/services/FmpStockListServiceTest.java b/src/test/java/dev/sorn/fmp4j/services/FmpStockListServiceTest.java index a9cc7cc8..266ccb99 100644 --- a/src/test/java/dev/sorn/fmp4j/services/FmpStockListServiceTest.java +++ b/src/test/java/dev/sorn/fmp4j/services/FmpStockListServiceTest.java @@ -64,4 +64,21 @@ void successful_download() { range(0, 2).forEach(i -> assertInstanceOf(FmpStock.class, result[i])); range(0, 2).forEach(i -> assertAllFieldsNonNull(result[i])); } + + @Test + void partial_successful_download() { + // given + httpStub.configureResponse() + .body(jsonTestResource("stable/stock-list/contains_invalid.json")) + .statusCode(200) + .apply(); + + // when + var result = service.download(); + + // then + assertEquals(2, result.length); + range(0, 2).forEach(i -> assertInstanceOf(FmpStock.class, result[i])); + range(0, 2).forEach(i -> assertAllFieldsNonNull(result[i])); + } } diff --git a/src/testFixtures/resources/stable/stock-list/contains_invalid.json b/src/testFixtures/resources/stable/stock-list/contains_invalid.json new file mode 100644 index 00000000..9ebdf22e --- /dev/null +++ b/src/testFixtures/resources/stable/stock-list/contains_invalid.json @@ -0,0 +1,14 @@ +[ + { + "symbol": "6898.HK", + "companyName": "China Aluminum Cans Holdings Limited" + }, + { + "symbol": "0700.HK", + "companyName": "Tencent Holdings Limited" + }, + { + "symbol": "$INVALID", + "companyName": "Some Invalid Company Limited" + } +] \ No newline at end of file