diff --git a/Makefile b/Makefile index c0c7cadf0..3721bf1c4 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ openapi-generator-cli:=java -jar $(openapi-generator-jar) generator:=java library:=okhttp-gson -services:=payments +services:=payments checkout models:=src/main/java/com/adyen/model output:=target/out @@ -41,11 +41,11 @@ $(services): target/spec $(openapi-generator-jar) --skip-validate-spec \ --model-package $(subst /,.,com.adyen.model.$@) \ --library $(library) \ - --global-property models \ --global-property modelDocs=false \ --global-property modelTests=false \ --additional-properties=dateLibrary=legacy mv $(output)/$(models)/$@ $(models)/$@ + mv $(output)/$(models)/JSON.java $(models)/$@ # Checkout spec (and patch version) diff --git a/README.md b/README.md index b3b742ee9..e6c0419b6 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ You can use Maven and add this dependency to your project's POM: com.adyen adyen-java-api-library - 18.1.1 + 18.1.2 ``` diff --git a/pom.xml b/pom.xml index f96e276e6..96760ae50 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ com.adyen adyen-java-api-library jar - 18.1.1 + 18.1.2 Adyen Java API Library Adyen API Client Library for Java https://github.com/adyen/adyen-java-api-library @@ -25,7 +25,7 @@ UTF-8 UTF-8 - 1.6.5 + 1.6.8 scm:git:git@github.com:Adyen/adyen-java-api-library.git @@ -191,7 +191,7 @@ com.fasterxml.jackson.core jackson-databind - 2.13.3 + 2.13.4.2 org.apache.httpcomponents.client5 @@ -224,12 +224,27 @@ io.swagger.core.v3 swagger-models - 2.2.2 + 2.2.4 io.swagger.core.v3 swagger-annotations - 2.2.2 + 2.2.4 + + + javax.ws.rs + javax.ws.rs-api + 2.1.1 + + + io.gsonfire + gson-fire + 1.8.5 + + + com.squareup.okio + okio + 3.2.0 diff --git a/src/main/java/com/adyen/Client.java b/src/main/java/com/adyen/Client.java index d960aeb0b..c2fd16420 100644 --- a/src/main/java/com/adyen/Client.java +++ b/src/main/java/com/adyen/Client.java @@ -47,7 +47,7 @@ public class Client { public static final String MARKETPAY_NOTIFICATION_API_VERSION = "v6"; public static final String MARKETPAY_HOP_API_VERSION = "v6"; public static final String LIB_NAME = "adyen-java-api-library"; - public static final String LIB_VERSION = "18.1.1"; + public static final String LIB_VERSION = "18.1.2"; public static final String CHECKOUT_ENDPOINT_TEST = "https://checkout-test.adyen.com/checkout"; public static final String CHECKOUT_ENDPOINT_LIVE_SUFFIX = "-checkout-live.adyenpayments.com/checkout"; public static final String CHECKOUT_ENDPOINT_CERT_LIVE = "https://checkoutcert-live-%s.adyen.com/checkout"; diff --git a/src/main/java/com/adyen/model/ThreeDSecureData.java b/src/main/java/com/adyen/model/ThreeDSecureData.java index ba35ab0b2..b4e445d0b 100644 --- a/src/main/java/com/adyen/model/ThreeDSecureData.java +++ b/src/main/java/com/adyen/model/ThreeDSecureData.java @@ -20,6 +20,7 @@ */ package com.adyen.model; +import com.adyen.serializer.ByteArrayToStringAdapter; import com.fasterxml.jackson.annotation.JsonValue; import com.google.gson.TypeAdapter; import com.google.gson.annotations.JsonAdapter; @@ -84,6 +85,7 @@ public AuthenticationResponseEnum read(final JsonReader jsonReader) throws IOExc private AuthenticationResponseEnum authenticationResponse = null; @SerializedName("cavv") + @JsonAdapter(ByteArrayToStringAdapter.class) private byte[] cavv = null; @SerializedName("cavvAlgorithm") @@ -203,12 +205,14 @@ public DirectoryResponseEnum read(final JsonReader jsonReader) throws IOExceptio private String threeDSVersion = null; @SerializedName("tokenAuthenticationVerificationValue") + @JsonAdapter(ByteArrayToStringAdapter.class) private byte[] tokenAuthenticationVerificationValue = null; @SerializedName("transStatusReason") private String transStatusReason = null; @SerializedName("xid") + @JsonAdapter(ByteArrayToStringAdapter.class) private byte[] xid = null; public ThreeDSecureData authenticationResponse(AuthenticationResponseEnum authenticationResponse) { diff --git a/src/main/java/com/adyen/terminal/security/NexoCrypto.java b/src/main/java/com/adyen/terminal/security/NexoCrypto.java index 42342cbdd..de3236614 100644 --- a/src/main/java/com/adyen/terminal/security/NexoCrypto.java +++ b/src/main/java/com/adyen/terminal/security/NexoCrypto.java @@ -41,12 +41,16 @@ import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.Random; +import java.security.SecureRandom; +import java.security.Provider; import static com.adyen.model.terminal.security.NexoDerivedKey.NEXO_IV_LENGTH; public class NexoCrypto { + private static SecureRandom secureRandom = new SecureRandom(); + private static final Provider PROVIDER = secureRandom.getProvider(); + public SaleToPOISecuredMessage encrypt( String saleToPoiMessageJson, MessageHeader messageHeader, SecurityKey securityKey) throws Exception { validateSecurityKey(securityKey); @@ -145,11 +149,16 @@ private void validateHmac(byte[] receivedHmac, byte[] decryptedMessage, NexoDeri } /** - * Generate a random iv nonce + * Generate a random iv nonce with cryptographically strongest non blocking RNG */ private byte[] generateRandomIvNonce() { byte[] ivNonce = new byte[NEXO_IV_LENGTH]; - new Random().nextBytes(ivNonce); + try { + secureRandom = SecureRandom.getInstance("NativePRNGNonBlocking", PROVIDER); + } catch (Exception NoSuchAlgorithmException) { + secureRandom = new SecureRandom(); + } + secureRandom.nextBytes(ivNonce); return ivNonce; } -} +} \ No newline at end of file diff --git a/src/test/java/com/adyen/PaymentTest.java b/src/test/java/com/adyen/PaymentTest.java index 32b6bd8c6..6dde271fb 100644 --- a/src/test/java/com/adyen/PaymentTest.java +++ b/src/test/java/com/adyen/PaymentTest.java @@ -20,22 +20,13 @@ */ package com.adyen; +import com.adyen.constants.ApiConstants; import com.adyen.constants.ApiConstants.AdditionalData; import com.adyen.constants.ApiConstants.RefusalReason; import com.adyen.httpclient.AdyenHttpClient; +import com.adyen.httpclient.ClientInterface; import com.adyen.httpclient.HTTPClientException; -import com.adyen.model.Address; -import com.adyen.model.AuthenticationResultRequest; -import com.adyen.model.AuthenticationResultResponse; -import com.adyen.model.FraudCheckResult; -import com.adyen.model.Name; -import com.adyen.model.PaymentRequest; -import com.adyen.model.PaymentRequest3d; -import com.adyen.model.PaymentRequest3ds2; -import com.adyen.model.PaymentResult; -import com.adyen.model.RequestOptions; -import com.adyen.model.ThreeDS2ResultRequest; -import com.adyen.model.ThreeDS2ResultResponse; +import com.adyen.model.*; import com.adyen.model.applicationinfo.ApplicationInfo; import com.adyen.model.applicationinfo.MerchantDevice; import com.adyen.service.Payment; @@ -43,6 +34,7 @@ import org.junit.Test; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.HashMap; @@ -51,18 +43,12 @@ import static com.adyen.constants.ApiConstants.SelectedBrand.BOLETO_SANTANDER; import static com.adyen.model.PaymentResult.ResultCodeEnum.RECEIVED; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; +import static org.junit.Assert.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.isNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; /** * Tests for /authorise and /authorise3d @@ -424,4 +410,18 @@ public void TestGetAuthenticationResultErrorNotAllowed() throws IOException { assertEquals(403, e.getError().getStatus()); } } + + @Test + public void TestByteArrayToJSONString() throws Exception { + Client client = createMockClientFromFile("mocks/authorise-success.json"); + Payment payment = new Payment(client); + PaymentRequest paymentRequest = new PaymentRequest(); + paymentRequest.mpiData(new ThreeDSecureData().cavv("AQIDBAUGBwgJCgsMDQ4PEBESExQ=".getBytes())); + + payment.authorise(paymentRequest); + + String expected = "\"mpiData\":{\"cavv\":\"AQIDBAUGBwgJCgsMDQ4PEBESExQ=\"}"; + ClientInterface http = client.getHttpClient(); + verify(http).request(anyString(), contains(expected), any(), eq(false), isNull(), any()); + } } diff --git a/templates/libraries/okhttp-gson/AbstractOpenApiSchema.mustache b/templates/libraries/okhttp-gson/AbstractOpenApiSchema.mustache new file mode 100644 index 000000000..0f85519ea --- /dev/null +++ b/templates/libraries/okhttp-gson/AbstractOpenApiSchema.mustache @@ -0,0 +1,134 @@ +{{>licenseInfo}} + +package {{modelPackage}}; + +import java.util.Objects; +import java.lang.reflect.Type; +import java.util.Map; +import javax.ws.rs.core.GenericType; + +/** + * Abstract class for oneOf,anyOf schemas defined in OpenAPI spec + */ +public abstract class AbstractOpenApiSchema { + + // store the actual instance of the schema/object + private Object instance; + + // is nullable + private Boolean isNullable; + + // schema type (e.g. oneOf, anyOf) + private final String schemaType; + + public AbstractOpenApiSchema(String schemaType, Boolean isNullable) { + this.schemaType = schemaType; + this.isNullable = isNullable; + } + + /** + * Get the list of oneOf/anyOf composed schemas allowed to be stored in this object + * + * @return an instance of the actual schema/object + */ + public abstract Map getSchemas(); + + /** + * Get the actual instance + * + * @return an instance of the actual schema/object + */ + //@JsonValue + public Object getActualInstance() {return instance;} + + /** + * Set the actual instance + * + * @param instance the actual instance of the schema/object + */ + public void setActualInstance(Object instance) {this.instance = instance;} + + /** + * Get the instant recursively when the schemas defined in oneOf/anyof happen to be oneOf/anyOf schema as well + * + * @return an instance of the actual schema/object + */ + public Object getActualInstanceRecursively() { + return getActualInstanceRecursively(this); + } + + private Object getActualInstanceRecursively(AbstractOpenApiSchema object) { + if (object.getActualInstance() == null) { + return null; + } else if (object.getActualInstance() instanceof AbstractOpenApiSchema) { + return getActualInstanceRecursively((AbstractOpenApiSchema)object.getActualInstance()); + } else { + return object.getActualInstance(); + } + } + + /** + * Get the schema type (e.g. anyOf, oneOf) + * + * @return the schema type + */ + public String getSchemaType() { + return schemaType; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class ").append(getClass()).append(" {\n"); + sb.append(" instance: ").append(toIndentedString(instance)).append("\n"); + sb.append(" isNullable: ").append(toIndentedString(isNullable)).append("\n"); + sb.append(" schemaType: ").append(toIndentedString(schemaType)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(Object o) { + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n "); + } + + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AbstractOpenApiSchema a = (AbstractOpenApiSchema) o; + return Objects.equals(this.instance, a.instance) && + Objects.equals(this.isNullable, a.isNullable) && + Objects.equals(this.schemaType, a.schemaType); + } + + @Override + public int hashCode() { + return Objects.hash(instance, isNullable, schemaType); + } + + /** + * Is nullable + * + * @return true if it's nullable + */ + public Boolean isNullable() { + if (Boolean.TRUE.equals(isNullable)) { + return Boolean.TRUE; + } else { + return Boolean.FALSE; + } + } + +{{>libraries/jersey2/additional_properties}} + +} diff --git a/templates/libraries/okhttp-gson/JSON.mustache b/templates/libraries/okhttp-gson/JSON.mustache new file mode 100644 index 000000000..437b75f86 --- /dev/null +++ b/templates/libraries/okhttp-gson/JSON.mustache @@ -0,0 +1,534 @@ +{{>licenseInfo}} + +package {{modelPackage}}; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.internal.bind.util.ISO8601Utils; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.google.gson.JsonElement; +import io.gsonfire.GsonFireBuilder; +import io.gsonfire.TypeSelector; +{{#joda}} +import org.joda.time.DateTime; +import org.joda.time.LocalDate; +import org.joda.time.format.DateTimeFormatter; +import org.joda.time.format.DateTimeFormatterBuilder; +import org.joda.time.format.ISODateTimeFormat; +{{/joda}} + +import okio.ByteString; + +import java.io.IOException; +import java.io.StringReader; +import java.lang.reflect.Type; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.ParsePosition; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Date; +import java.util.Locale; +import java.util.Map; +import java.util.HashMap; + +/* + * A JSON utility class + * + * NOTE: in the future, this class may be converted to static, which may break + * backward-compatibility + */ +public class JSON { + private static Gson gson; + private static boolean isLenientOnJson = false; + private static DateTypeAdapter dateTypeAdapter = new DateTypeAdapter(); + private static SqlDateTypeAdapter sqlDateTypeAdapter = new SqlDateTypeAdapter(); + {{#joda}} + private static DateTimeTypeAdapter dateTimeTypeAdapter = new DateTimeTypeAdapter(); + private static LocalDateTypeAdapter localDateTypeAdapter = new LocalDateTypeAdapter(); + {{/joda}} + {{#jsr310}} + private static OffsetDateTimeTypeAdapter offsetDateTimeTypeAdapter = new OffsetDateTimeTypeAdapter(); + private static LocalDateTypeAdapter localDateTypeAdapter = new LocalDateTypeAdapter(); + {{/jsr310}} + private static ByteArrayAdapter byteArrayAdapter = new ByteArrayAdapter(); + + @SuppressWarnings("unchecked") + public static GsonBuilder createGson() { + GsonFireBuilder fireBuilder = new GsonFireBuilder() + {{#models}} + {{#model}} + {{#discriminator}} + .registerTypeSelector({{modelPackage}}.{{classname}}.class, new TypeSelector<{{modelPackage}}.{{classname}}>() { + @Override + public Class getClassForElement(JsonElement readElement) { + Map classByDiscriminatorValue = new HashMap(); + {{#mappedModels}} + classByDiscriminatorValue.put("{{mappingName}}"{{^discriminatorCaseSensitive}}.toUpperCase(Locale.ROOT){{/discriminatorCaseSensitive}}, {{modelPackage}}.{{modelName}}.class); + {{/mappedModels}} + classByDiscriminatorValue.put("{{name}}"{{^discriminatorCaseSensitive}}.toUpperCase(Locale.ROOT){{/discriminatorCaseSensitive}}, {{modelPackage}}.{{classname}}.class); + return getClassByDiscriminator(classByDiscriminatorValue, + getDiscriminatorValue(readElement, "{{{propertyBaseName}}}")); + } + }) + {{/discriminator}} + {{/model}} + {{/models}} + ; + GsonBuilder builder = fireBuilder.createGsonBuilder(); + {{#disableHtmlEscaping}} + builder.disableHtmlEscaping(); + {{/disableHtmlEscaping}} + return builder; + } + + private static String getDiscriminatorValue(JsonElement readElement, String discriminatorField) { + JsonElement element = readElement.getAsJsonObject().get(discriminatorField); + if (null == element) { + throw new IllegalArgumentException("missing discriminator field: <" + discriminatorField + ">"); + } + return element.getAsString(); + } + + /** + * Returns the Java class that implements the OpenAPI schema for the specified discriminator value. + * + * @param classByDiscriminatorValue The map of discriminator values to Java classes. + * @param discriminatorValue The value of the OpenAPI discriminator in the input data. + * @return The Java class that implements the OpenAPI schema + */ + private static Class getClassByDiscriminator(Map classByDiscriminatorValue, String discriminatorValue) { + Class clazz = (Class) classByDiscriminatorValue.get(discriminatorValue{{^discriminatorCaseSensitive}}.toUpperCase(Locale.ROOT){{/discriminatorCaseSensitive}}); + if (null == clazz) { + throw new IllegalArgumentException("cannot determine model class of name: <" + discriminatorValue + ">"); + } + return clazz; + } + + { + GsonBuilder gsonBuilder = createGson(); + gsonBuilder.registerTypeAdapter(Date.class, dateTypeAdapter); + gsonBuilder.registerTypeAdapter(java.sql.Date.class, sqlDateTypeAdapter); + {{#joda}} + gsonBuilder.registerTypeAdapter(DateTime.class, dateTimeTypeAdapter); + gsonBuilder.registerTypeAdapter(LocalDate.class, localDateTypeAdapter); + {{/joda}} + {{#jsr310}} + gsonBuilder.registerTypeAdapter(OffsetDateTime.class, offsetDateTimeTypeAdapter); + gsonBuilder.registerTypeAdapter(LocalDate.class, localDateTypeAdapter); + {{/jsr310}} + gsonBuilder.registerTypeAdapter(byte[].class, byteArrayAdapter); + {{#models}} + {{#model}} + {{^isEnum}} + {{^hasChildren}} + gsonBuilder.registerTypeAdapterFactory(new {{modelPackage}}.{{{classname}}}.CustomTypeAdapterFactory()); + {{/hasChildren}} + {{/isEnum}} + {{/model}} + {{/models}} + gson = gsonBuilder.create(); + } + + /** + * Get Gson. + * + * @return Gson + */ + public static Gson getGson() { + return gson; + } + + /** + * Set Gson. + * + * @param gson Gson + */ + public static void setGson(Gson gson) { + JSON.gson = gson; + } + + public static void setLenientOnJson(boolean lenientOnJson) { + isLenientOnJson = lenientOnJson; + } + + /** + * Serialize the given Java object into JSON string. + * + * @param obj Object + * @return String representation of the JSON + */ + public static String serialize(Object obj) { + return gson.toJson(obj); + } + + /** + * Deserialize the given JSON string to Java object. + * + * @param Type + * @param body The JSON string + * @param returnType The type to deserialize into + * @return The deserialized Java object + */ + @SuppressWarnings("unchecked") + public static T deserialize(String body, Type returnType) { + try { + if (isLenientOnJson) { + JsonReader jsonReader = new JsonReader(new StringReader(body)); + // see https://google-gson.googlecode.com/svn/trunk/gson/docs/javadocs/com/google/gson/stream/JsonReader.html#setLenient(boolean) + jsonReader.setLenient(true); + return gson.fromJson(jsonReader, returnType); + } else { + return gson.fromJson(body, returnType); + } + } catch (JsonParseException e) { + // Fallback processing when failed to parse JSON form response body: + // return the response body string directly for the String return type; + if (returnType.equals(String.class)) { + return (T) body; + } else { + throw (e); + } + } + } + + /** + * Gson TypeAdapter for Byte Array type + */ + public static class ByteArrayAdapter extends TypeAdapter { + + @Override + public void write(JsonWriter out, byte[] value) throws IOException { + if (value == null) { + out.nullValue(); + } else { + out.value(ByteString.of(value).base64()); + } + } + + @Override + public byte[] read(JsonReader in) throws IOException { + switch (in.peek()) { + case NULL: + in.nextNull(); + return null; + default: + String bytesAsBase64 = in.nextString(); + ByteString byteString = ByteString.decodeBase64(bytesAsBase64); + return byteString.toByteArray(); + } + } + } + + {{#joda}} + /** + * Gson TypeAdapter for Joda DateTime type + */ + public static class DateTimeTypeAdapter extends TypeAdapter { + + private DateTimeFormatter formatter; + + public DateTimeTypeAdapter() { + this(new DateTimeFormatterBuilder() + .append(ISODateTimeFormat.dateTime().getPrinter(), ISODateTimeFormat.dateOptionalTimeParser().getParser()) + .toFormatter()); + } + + public DateTimeTypeAdapter(DateTimeFormatter formatter) { + this.formatter = formatter; + } + + public void setFormat(DateTimeFormatter dateFormat) { + this.formatter = dateFormat; + } + + @Override + public void write(JsonWriter out, DateTime date) throws IOException { + if (date == null) { + out.nullValue(); + } else { + out.value(formatter.print(date)); + } + } + + @Override + public DateTime read(JsonReader in) throws IOException { + switch (in.peek()) { + case NULL: + in.nextNull(); + return null; + default: + String date = in.nextString(); + return formatter.parseDateTime(date); + } + } + } + + /** + * Gson TypeAdapter for Joda LocalDate type + */ + public static class LocalDateTypeAdapter extends TypeAdapter { + + private DateTimeFormatter formatter; + + public LocalDateTypeAdapter() { + this(ISODateTimeFormat.date()); + } + + public LocalDateTypeAdapter(DateTimeFormatter formatter) { + this.formatter = formatter; + } + + public void setFormat(DateTimeFormatter dateFormat) { + this.formatter = dateFormat; + } + + @Override + public void write(JsonWriter out, LocalDate date) throws IOException { + if (date == null) { + out.nullValue(); + } else { + out.value(formatter.print(date)); + } + } + + @Override + public LocalDate read(JsonReader in) throws IOException { + switch (in.peek()) { + case NULL: + in.nextNull(); + return null; + default: + String date = in.nextString(); + return formatter.parseLocalDate(date); + } + } + } + + public static void setDateTimeFormat(DateTimeFormatter dateFormat) { + dateTimeTypeAdapter.setFormat(dateFormat); + } + + public static void setLocalDateFormat(DateTimeFormatter dateFormat) { + localDateTypeAdapter.setFormat(dateFormat); + } + + {{/joda}} + {{#jsr310}} + /** + * Gson TypeAdapter for JSR310 OffsetDateTime type + */ + public static class OffsetDateTimeTypeAdapter extends TypeAdapter { + + private DateTimeFormatter formatter; + + public OffsetDateTimeTypeAdapter() { + this(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } + + public OffsetDateTimeTypeAdapter(DateTimeFormatter formatter) { + this.formatter = formatter; + } + + public void setFormat(DateTimeFormatter dateFormat) { + this.formatter = dateFormat; + } + + @Override + public void write(JsonWriter out, OffsetDateTime date) throws IOException { + if (date == null) { + out.nullValue(); + } else { + out.value(formatter.format(date)); + } + } + + @Override + public OffsetDateTime read(JsonReader in) throws IOException { + switch (in.peek()) { + case NULL: + in.nextNull(); + return null; + default: + String date = in.nextString(); + if (date.endsWith("+0000")) { + date = date.substring(0, date.length()-5) + "Z"; + } + return OffsetDateTime.parse(date, formatter); + } + } + } + + /** + * Gson TypeAdapter for JSR310 LocalDate type + */ + public static class LocalDateTypeAdapter extends TypeAdapter { + + private DateTimeFormatter formatter; + + public LocalDateTypeAdapter() { + this(DateTimeFormatter.ISO_LOCAL_DATE); + } + + public LocalDateTypeAdapter(DateTimeFormatter formatter) { + this.formatter = formatter; + } + + public void setFormat(DateTimeFormatter dateFormat) { + this.formatter = dateFormat; + } + + @Override + public void write(JsonWriter out, LocalDate date) throws IOException { + if (date == null) { + out.nullValue(); + } else { + out.value(formatter.format(date)); + } + } + + @Override + public LocalDate read(JsonReader in) throws IOException { + switch (in.peek()) { + case NULL: + in.nextNull(); + return null; + default: + String date = in.nextString(); + return LocalDate.parse(date, formatter); + } + } + } + + public static void setOffsetDateTimeFormat(DateTimeFormatter dateFormat) { + offsetDateTimeTypeAdapter.setFormat(dateFormat); + } + + public static void setLocalDateFormat(DateTimeFormatter dateFormat) { + localDateTypeAdapter.setFormat(dateFormat); + } + + {{/jsr310}} + /** + * Gson TypeAdapter for java.sql.Date type + * If the dateFormat is null, a simple "yyyy-MM-dd" format will be used + * (more efficient than SimpleDateFormat). + */ + public static class SqlDateTypeAdapter extends TypeAdapter { + + private DateFormat dateFormat; + + public SqlDateTypeAdapter() {} + + public SqlDateTypeAdapter(DateFormat dateFormat) { + this.dateFormat = dateFormat; + } + + public void setFormat(DateFormat dateFormat) { + this.dateFormat = dateFormat; + } + + @Override + public void write(JsonWriter out, java.sql.Date date) throws IOException { + if (date == null) { + out.nullValue(); + } else { + String value; + if (dateFormat != null) { + value = dateFormat.format(date); + } else { + value = date.toString(); + } + out.value(value); + } + } + + @Override + public java.sql.Date read(JsonReader in) throws IOException { + switch (in.peek()) { + case NULL: + in.nextNull(); + return null; + default: + String date = in.nextString(); + try { + if (dateFormat != null) { + return new java.sql.Date(dateFormat.parse(date).getTime()); + } + return new java.sql.Date(ISO8601Utils.parse(date, new ParsePosition(0)).getTime()); + } catch (ParseException e) { + throw new JsonParseException(e); + } + } + } + } + + /** + * Gson TypeAdapter for java.util.Date type + * If the dateFormat is null, ISO8601Utils will be used. + */ + public static class DateTypeAdapter extends TypeAdapter { + + private DateFormat dateFormat; + + public DateTypeAdapter() {} + + public DateTypeAdapter(DateFormat dateFormat) { + this.dateFormat = dateFormat; + } + + public void setFormat(DateFormat dateFormat) { + this.dateFormat = dateFormat; + } + + @Override + public void write(JsonWriter out, Date date) throws IOException { + if (date == null) { + out.nullValue(); + } else { + String value; + if (dateFormat != null) { + value = dateFormat.format(date); + } else { + value = ISO8601Utils.format(date, true); + } + out.value(value); + } + } + + @Override + public Date read(JsonReader in) throws IOException { + try { + switch (in.peek()) { + case NULL: + in.nextNull(); + return null; + default: + String date = in.nextString(); + try { + if (dateFormat != null) { + return dateFormat.parse(date); + } + return ISO8601Utils.parse(date, new ParsePosition(0)); + } catch (ParseException e) { + throw new JsonParseException(e); + } + } + } catch (IllegalArgumentException e) { + throw new JsonParseException(e); + } + } + } + + public static void setDateFormat(DateFormat dateFormat) { + dateTypeAdapter.setFormat(dateFormat); + } + + public static void setSqlDateFormat(DateFormat dateFormat) { + sqlDateTypeAdapter.setFormat(dateFormat); + } +} diff --git a/templates/libraries/okhttp-gson/oneof_model.mustache b/templates/libraries/okhttp-gson/oneof_model.mustache new file mode 100644 index 000000000..8748d06be --- /dev/null +++ b/templates/libraries/okhttp-gson/oneof_model.mustache @@ -0,0 +1,250 @@ +import javax.ws.rs.core.GenericType; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.HashMap; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParseException; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.JsonPrimitive; +import com.google.gson.annotations.JsonAdapter; +import com.google.gson.annotations.SerializedName; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; + +import {{package}}.JSON; + +{{>additionalModelTypeAnnotations}}{{>xmlAnnotation}} +public class {{classname}} extends AbstractOpenApiSchema{{#vendorExtensions.x-implements}}, {{{.}}}{{/vendorExtensions.x-implements}} { + private static final Logger log = Logger.getLogger({{classname}}.class.getName()); + + public static class CustomTypeAdapterFactory implements TypeAdapterFactory { + @SuppressWarnings("unchecked") + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if (!{{classname}}.class.isAssignableFrom(type.getRawType())) { + return null; // this class only serializes '{{classname}}' and its subtypes + } + final TypeAdapter elementAdapter = gson.getAdapter(JsonElement.class); + {{#oneOf}} + final TypeAdapter<{{.}}> adapter{{.}} = gson.getDelegateAdapter(this, TypeToken.get({{.}}.class)); + {{/oneOf}} + + return (TypeAdapter) new TypeAdapter<{{classname}}>() { + @Override + public void write(JsonWriter out, {{classname}} value) throws IOException { + if (value == null || value.getActualInstance() == null) { + elementAdapter.write(out, null); + return; + } + + {{#oneOf}} + // check if the actual instance is of the type `{{.}}` + if (value.getActualInstance() instanceof {{.}}) { + JsonObject obj = adapter{{.}}.toJsonTree(({{.}})value.getActualInstance()).getAsJsonObject(); + elementAdapter.write(out, obj); + return; + } + + {{/oneOf}} + throw new IOException("Failed to serialize as the type doesn't match oneOf schemas: {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}"); + } + + @Override + public {{classname}} read(JsonReader in) throws IOException { + Object deserialized = null; + JsonObject jsonObject = elementAdapter.read(in).getAsJsonObject(); + + {{#useOneOfDiscriminatorLookup}} + {{#discriminator}} + // use discriminator value for faster oneOf lookup + {{classname}} new{{classname}} = new {{classname}}(); + if (jsonObject.get("{{{propertyBaseName}}}") == null) { + log.log(Level.WARNING, "Failed to lookup discriminator value for {{classname}} as `{{{propertyBaseName}}}` was not found in the payload or the payload is empty."); + } else { + // look up the discriminator value in the field `{{{propertyBaseName}}}` + switch (jsonObject.get("{{{propertyBaseName}}}").getAsString()) { + {{#mappedModels}} + case "{{{mappingName}}}": + deserialized = adapter{{modelName}}.fromJsonTree(jsonObject); + new{{classname}}.setActualInstance(deserialized); + return new{{classname}}; + {{/mappedModels}} + default: + log.log(Level.WARNING, String.format("Failed to lookup discriminator value `%s` for {{classname}}. Possible values:{{#mappedModels}} {{{mappingName}}}{{/mappedModels}}", jsonObject.get("{{{propertyBaseName}}}").getAsString())); + } + } + + {{/discriminator}} + {{/useOneOfDiscriminatorLookup}} + int match = 0; + ArrayList errorMessages = new ArrayList<>(); + TypeAdapter actualAdapter = elementAdapter; + + {{#oneOf}} + // deserialize {{{.}}} + try { + // validate the JSON object to see if any exception is thrown + {{.}}.validateJsonObject(jsonObject); + actualAdapter = adapter{{.}}; + match++; + log.log(Level.FINER, "Input data matches schema '{{{.}}}'"); + } catch (Exception e) { + // deserialization failed, continue + errorMessages.add(String.format("Deserialization for {{{.}}} failed with `%s`.", e.getMessage())); + log.log(Level.FINER, "Input data does not match schema '{{{.}}}'", e); + } + + {{/oneOf}} + if (match == 1) { + {{classname}} ret = new {{classname}}(); + ret.setActualInstance(actualAdapter.fromJsonTree(jsonObject)); + return ret; + } + + throw new IOException(String.format("Failed deserialization for {{classname}}: %d classes match result, expected 1. Detailed failure message for oneOf schemas: %s. JSON: %s", match, errorMessages, jsonObject.toString())); + } + }.nullSafe(); + } + } + + // store a list of schema names defined in oneOf + public static final Map schemas = new HashMap(); + + public {{classname}}() { + super("oneOf", {{#isNullable}}Boolean.TRUE{{/isNullable}}{{^isNullable}}Boolean.FALSE{{/isNullable}}); + } + + {{#oneOf}} + public {{classname}}({{{.}}} o) { + super("oneOf", {{#isNullable}}Boolean.TRUE{{/isNullable}}{{^isNullable}}Boolean.FALSE{{/isNullable}}); + setActualInstance(o); + } + + {{/oneOf}} + static { + {{#oneOf}} + schemas.put("{{{.}}}", new GenericType<{{{.}}}>() { + }); + {{/oneOf}} + } + + @Override + public Map getSchemas() { + return {{classname}}.schemas; + } + + /** + * Set the instance that matches the oneOf child schema, check + * the instance parameter is valid against the oneOf child schemas: + * {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}} + * + * It could be an instance of the 'oneOf' schemas. + * The oneOf child schemas may themselves be a composed schema (allOf, anyOf, oneOf). + */ + @Override + public void setActualInstance(Object instance) { + {{#isNullable}} + if (instance == null) { + super.setActualInstance(instance); + return; + } + + {{/isNullable}} + {{#oneOf}} + if (instance instanceof {{{.}}}) { + super.setActualInstance(instance); + return; + } + + {{/oneOf}} + throw new RuntimeException("Invalid instance type. Must be {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}"); + } + + /** + * Get the actual instance, which can be the following: + * {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}} + * + * @return The actual instance ({{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}) + */ + @Override + public Object getActualInstance() { + return super.getActualInstance(); + } + + {{#oneOf}} + /** + * Get the actual instance of `{{{.}}}`. If the actual instance is not `{{{.}}}`, + * the ClassCastException will be thrown. + * + * @return The actual instance of `{{{.}}}` + * @throws ClassCastException if the instance is not `{{{.}}}` + */ + public {{{.}}} get{{{.}}}() throws ClassCastException { + return ({{{.}}})super.getActualInstance(); + } + + {{/oneOf}} + + /** + * Validates the JSON Object and throws an exception if issues found + * + * @param jsonObj JSON Object + * @throws IOException if the JSON Object is invalid with respect to {{classname}} + */ + public static void validateJsonObject(JsonObject jsonObj) throws IOException { + // validate oneOf schemas one by one + int validCount = 0; + ArrayList errorMessages = new ArrayList<>(); + {{#oneOf}} + // validate the json string with {{{.}}} + try { + {{{.}}}.validateJsonObject(jsonObj); + validCount++; + } catch (Exception e) { + errorMessages.add(String.format("Deserialization for {{{.}}} failed with `%s`.", e.getMessage())); + // continue to the next one + } + {{/oneOf}} + if (validCount != 1) { + throw new IOException(String.format("The JSON string is invalid for {{classname}} with oneOf schemas: {{#oneOf}}{{{.}}}{{^-last}}, {{/-last}}{{/oneOf}}. %d class(es) match the result, expected 1. Detailed failure message for oneOf schemas: %s. JSON: %s", validCount, errorMessages, jsonObj.toString())); + } + } + + /** + * Create an instance of {{classname}} given an JSON string + * + * @param jsonString JSON string + * @return An instance of {{classname}} + * @throws IOException if the JSON string is invalid with respect to {{classname}} + */ + public static {{{classname}}} fromJson(String jsonString) throws IOException { + return JSON.getGson().fromJson(jsonString, {{{classname}}}.class); + } + + /** + * Convert an instance of {{classname}} to an JSON string + * + * @return JSON string + */ + public String toJson() { + return JSON.getGson().toJson(this); + } +} diff --git a/templates/libraries/okhttp-gson/pojo.mustache b/templates/libraries/okhttp-gson/pojo.mustache index 8ae82cc72..eec760d55 100644 --- a/templates/libraries/okhttp-gson/pojo.mustache +++ b/templates/libraries/okhttp-gson/pojo.mustache @@ -16,6 +16,8 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Set; +import {{package}}.JSON; + /** * {{description}}{{^description}}{{classname}}{{/description}}{{#isDeprecated}} * @deprecated{{/isDeprecated}} @@ -488,10 +490,22 @@ public class {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{{#vendorExtens {{/isArray}} {{^isContainer}} {{#isString}} + {{^isEnum}} + // validate the {{#isRequired}}required{{/isRequired}}{{^isRequired}}optional{{/isRequired}} field {{{baseName}}} if ({{^isRequired}}jsonObj.get("{{{baseName}}}") != null && {{/isRequired}}!jsonObj.get("{{{baseName}}}").isJsonPrimitive()) { throw new IllegalArgumentException(String.format("Expected the field `{{{baseName}}}` to be a primitive type in the JSON string but got `%s`", jsonObj.get("{{{baseName}}}").toString())); } + {{/isEnum}} {{/isString}} + {{#isEnum}} + // ensure the field {{{baseName}}} can be parsed to an enum value + if ({{^isRequired}}jsonObj.get("{{{baseName}}}") != null) { + if({{/isRequired}}!jsonObj.get("{{{baseName}}}").isJsonPrimitive()) { + throw new IllegalArgumentException(String.format("Expected the field `{{{baseName}}}` to be a primitive type in the JSON string but got `%s`", jsonObj.get("{{{baseName}}}").toString())); + } + {{{datatypeWithEnum}}}{{^datatypeWithEnum}}{{classname}}{{/datatypeWithEnum}}.fromValue(jsonObj.get("{{{baseName}}}").getAsString()); + } + {{/isEnum}} {{#isModel}} {{#isRequired}} // validate the required field `{{{baseName}}}` @@ -517,7 +531,7 @@ public class {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{{#vendorExtens {{modelName}}.validateJsonObject(jsonObj); break; {{/mappedModels}} - default: + default: throw new IllegalArgumentException(String.format("The value of the `{{{propertyBaseName}}}` field `%s` does not match any key defined in the discriminator's mapping.", discriminatorValue)); } {{/discriminator}} @@ -597,4 +611,23 @@ public class {{classname}} {{#parent}}extends {{{.}}} {{/parent}}{{#vendorExtens } {{/hasChildren}} + /** + * Create an instance of {{classname}} given an JSON string + * + * @param jsonString JSON string + * @return An instance of {{classname}} + * @throws IOException if the JSON string is invalid with respect to {{classname}} + */ + public static {{{classname}}} fromJson(String jsonString) throws IOException { + return JSON.getGson().fromJson(jsonString, {{{classname}}}.class); + } + + /** + * Convert an instance of {{classname}} to an JSON string + * + * @return JSON string + */ + public String toJson() { + return JSON.getGson().toJson(this); + } }