Repository: swagger-api/swagger-parser
Pull request #1062 (2019-03-26 19:29:40)

(issue #1049) OpenAPIDeserializer: Rework handling of formatted string values using getDecodedObject()


Patch:
diff --git a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java
index c03ab661..fd0c604b 100644
--- a/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java
+++ b/modules/swagger-parser-v3/src/main/java/io/swagger/v3/parser/util/OpenAPIDeserializer.java
@@ -17,8 +17,11 @@ import io.swagger.v3.oas.models.links.Link;
 import io.swagger.v3.oas.models.info.Contact;
 import io.swagger.v3.oas.models.info.Info;
 import io.swagger.v3.oas.models.media.ArraySchema;
+import io.swagger.v3.oas.models.media.ByteArraySchema;
 import io.swagger.v3.oas.models.media.ComposedSchema;
 import io.swagger.v3.oas.models.media.Content;
+import io.swagger.v3.oas.models.media.DateSchema;
+import io.swagger.v3.oas.models.media.DateTimeSchema;
 import io.swagger.v3.oas.models.media.Discriminator;
 import io.swagger.v3.oas.models.media.Encoding;
 import io.swagger.v3.oas.models.media.MediaType;
@@ -56,10 +59,13 @@ import java.math.BigDecimal;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
+import java.text.ParseException;
 import java.util.*;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import static java.util.Calendar.*;
 
 
 public class OpenAPIDeserializer {
@@ -94,6 +100,9 @@ public class OpenAPIDeserializer {
     private static final String COOKIE_PARAMETER = "cookie";
     private static final String PATH_PARAMETER = "path";
     private static final String HEADER_PARAMETER = "header";
+    private static final Pattern RFC3339_DATE_TIME_PATTERN = Pattern.compile( "^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(\\.\\d+)?((Z)|([+-]\\d{2}:\\d{2}))$");
+    private static final Pattern RFC3339_DATE_PATTERN = Pattern.compile( "^(\\d{4})-(\\d{2})-(\\d{2})$");
+
     private Components components;
     private final Set<String> operationIDs = new HashSet<>();
 
@@ -2201,8 +2210,13 @@ public class OpenAPIDeserializer {
             for (JsonNode n : enumArray) {
                 if (n.isNumber()) {
                     schema.addEnumItemObject(n.numberValue());
-                }else if (n.isValueNode()) {
-                    schema.addEnumItemObject(n.asText());
+                } else if (n.isValueNode()) {
+                    try {
+                        schema.addEnumItemObject( getDecodedObject( schema, n.asText(null)));
+                    }
+                    catch( ParseException e) {
+                        result.invalidType( location, String.format( "enum=`%s`", e.getMessage()), schema.getFormat(), n);
+                    }
                 } else {
                     result.invalidType(location, "enum", "value", n);
                 }
@@ -2291,7 +2305,12 @@ public class OpenAPIDeserializer {
             }else if(schema.getType().equals("string")) {
                 value = getString("default", node, false, location, result);
                 if (value != null) {
-                    schema.setDefault(value);
+                    try {
+                        schema.setDefault( getDecodedObject( schema, value));
+                    }
+                    catch( ParseException e) {
+                        result.invalidType( location, String.format( "default=`%s`", e.getMessage()), schema.getFormat(), node);
+                    }
                 }
             }else if(schema.getType().equals("boolean")) {
                 bool = getBoolean("default", node, false, location, result);
@@ -2375,6 +2394,121 @@ public class OpenAPIDeserializer {
     }
 
 
+    /**
+     * Decodes the given string and returns an object applicable to the given schema.
+     * Throws a ParseException if no applicable object can be recognized.
+     */
+    private Object getDecodedObject( Schema schema, String objectString) throws ParseException {
+        Object object = 
+            objectString == null?
+            null :
+
+            schema.getClass().equals( DateSchema.class)?
+            toDate( objectString) :
+
+            schema.getClass().equals( DateTimeSchema.class)?
+            toDateTime( objectString) :
+
+            schema.getClass().equals( ByteArraySchema.class)?
+            toBytes( objectString) :
+
+            objectString;
+
+        if( object == null && objectString != null) {
+            throw new ParseException( objectString, 0);
+        }
+
+        return object;
+    }
+
+
+    /**
+     * Returns the Date represented by the given RFC3339 date-time string.
+     * Returns null if this string can't be parsed as Date.
+     */
+    private Date toDateTime( String dateString) {
+        // Note: For this conversion, regex matching is better than SimpleDateFormat, etc.
+        // Optional elements (e.g. milliseconds) are not directly handled by SimpleDateFormat.
+        // Also, SimpleDateFormat is not thread-safe.
+        Matcher matcher = RFC3339_DATE_TIME_PATTERN.matcher( dateString);
+
+        Date dateTime = null;
+        if( matcher.matches()) {
+            try {
+                String year = matcher.group(1);
+                String month = matcher.group(2);
+                String day = matcher.group(3);
+                String hour = matcher.group(4);
+                String min = matcher.group(5);
+                String sec = matcher.group(6);
+                String ms = matcher.group(7);
+                String zone = matcher.group(10);
+
+                Calendar calendar = Calendar.getInstance( TimeZone.getTimeZone( zone == null? "GMT" : "GMT" + zone));
+                calendar.set( YEAR, Integer.parseInt( year));
+                calendar.set( MONTH, Integer.parseInt( month) - 1);
+                calendar.set( DAY_OF_MONTH, Integer.parseInt( day));
+                calendar.set( HOUR_OF_DAY, Integer.parseInt( hour));
+                calendar.set( MINUTE, Integer.parseInt( min));
+                calendar.set( SECOND, Integer.parseInt( sec));
+                calendar.set( MILLISECOND, ms == null? 0 : (int) (Double.parseDouble( ms) * 1000));
+
+                dateTime = calendar.getTime();
+            }
+            catch( Exception ignore) {
+            }
+        }
+
+        return dateTime;
+    }
+    
+
+  /**
+   * Returns the Date represented by the given RFC3339 full-date string.
+   * Returns null if this string can't be parsed as Date.
+   */
+    private Date toDate( String dateString) {
+        Matcher matcher = RFC3339_DATE_PATTERN.matcher( dateString);
+
+        Date date = null;
+        if( matcher.matches()) {
+            String year = matcher.group(1);
+            String month = matcher.group(2);
+            String day = matcher.group(3);
+
+            try {
+                date=
+                    new Calendar.Builder()
+                    .setDate( Integer.parseInt( year), Integer.parseInt( month) - 1, Integer.parseInt( day))
+                    .build()
+                    .getTime();
+            }
+            catch( Exception ignore) {
+            }
+        }
+
+        return date;
+    }
+    
+
+  /**
+   * Returns the byte array represented by the given base64-encoded string.
+   * Returns null if this string is not a valid base64 encoding.
+   */
+    private byte[] toBytes( String byteString) {
+        byte[] bytes;
+        
+        try {
+            bytes = Base64.getDecoder().decode( byteString);
+        }
+        catch( Exception e) {
+            bytes = null;
+        }
+
+        return bytes;
+    }
+
+
 
 
     public Map<String, Example> getExamples(ObjectNode obj, String location, ParseResult result) {


Test patch:
diff --git a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/util/OpenAPIDeserializerTest.java b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/util/OpenAPIDeserializerTest.java
index 49b6f139..dca3900b 100644
--- a/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/util/OpenAPIDeserializerTest.java
+++ b/modules/swagger-parser-v3/src/test/java/io/swagger/v3/parser/util/OpenAPIDeserializerTest.java
@@ -13,8 +13,11 @@ import io.swagger.v3.oas.models.Paths;
 import io.swagger.v3.oas.models.headers.Header;
 import io.swagger.v3.oas.models.media.ArraySchema;
 import io.swagger.v3.oas.models.media.BinarySchema;
+import io.swagger.v3.oas.models.media.ByteArraySchema;
 import io.swagger.v3.oas.models.media.ComposedSchema;
 import io.swagger.v3.oas.models.media.Content;
+import io.swagger.v3.oas.models.media.DateSchema;
+import io.swagger.v3.oas.models.media.DateTimeSchema;
 import io.swagger.v3.oas.models.media.IntegerSchema;
 import io.swagger.v3.oas.models.media.MediaType;
 import io.swagger.v3.oas.models.media.ObjectSchema;
@@ -45,10 +48,14 @@ import org.testng.annotations.Test;
 
 import java.math.BigDecimal;
 import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.TimeZone;
 
 import static org.testng.Assert.assertEquals;
 import static org.testng.Assert.assertNotNull;
@@ -796,6 +803,222 @@ public class OpenAPIDeserializerTest {
         assertEquals(numberImpl.getDefault(), new BigDecimal("3.14"));
     }
 
+    @Test
+    public void testDeserializeDateString() {
+        String yaml = "openapi: 3.0.0\n" +
+                "servers: []\n" +
+                "info:\n" +
+                "  version: 0.0.0\n" +
+                "  title: My Title\n" +
+                "paths:\n" +
+                "  /persons:\n" +
+                "    get:\n" +
+                "      description: a test\n" +
+                "      responses:\n" +
+                "        '200':\n" +
+                "          description: Successful response\n" +
+                "          content:\n" +
+                "            '*/*':\n" +
+                "              schema:\n" +
+                "                type: object\n" +
+                "                properties:\n" +
+                "                  date:\n" +
+                "                    $ref: '#/components/schemas/DateString'\n" +
+                "components:\n" +
+                "  schemas:\n" +
+                "    DateString:\n" +
+                "      type: string\n" +
+                "      format: date\n" +
+                "      default: 2019-01-01\n" +
+                "      enum:\n" +
+                "        - 2019-01-01\n" +
+                "        - Nope\n" +
+                "        - 2018-02-02\n" +
+                "        - 2017-03-03\n" +
+                "        - null\n" +
+                "";
+        OpenAPIV3Parser parser = new OpenAPIV3Parser();
+        SwaggerParseResult result = parser.readContents(yaml, null, null);
+
+        final OpenAPI resolved = new OpenAPIResolver(result.getOpenAPI(), null).resolve();
+
+        Schema dateModel = resolved.getComponents().getSchemas().get("DateString");
+        assertTrue(dateModel instanceof DateSchema);
+        List<Date> dateValues = dateModel.getEnum();
+        assertEquals(dateValues.size(), 4);
+        assertEquals(
+          dateValues.get(0),
+          new Calendar.Builder().setDate( 2019, 0, 1).build().getTime());
+        assertEquals(
+          dateValues.get(1),
+          new Calendar.Builder().setDate( 2018, 1, 2).build().getTime());
+        assertEquals(
+          dateValues.get(2),
+          new Calendar.Builder().setDate( 2017, 2, 3).build().getTime());
+        assertEquals(
+          dateValues.get(3),
+          null);
+
+        assertEquals(
+          dateModel.getDefault(),
+          new Calendar.Builder().setDate( 2019, 0, 1).build().getTime());
+
+        assertEquals(
+          result.getMessages(),
+          Arrays.asList( "attribute components.schemas.DateString.enum=`Nope` is not of type `date`"));
+    }
+
+    @Test
+    public void testDeserializeDateTimeString() {
+        String yaml = "openapi: 3.0.0\n" +
+                "servers: []\n" +
+                "info:\n" +
+                "  version: 0.0.0\n" +
+                "  title: My Title\n" +
+                "paths:\n" +
+                "  /persons:\n" +
+                "    get:\n" +
+                "      description: a test\n" +
+                "      responses:\n" +
+                "        '200':\n" +
+                "          description: Successful response\n" +
+                "          content:\n" +
+                "            '*/*':\n" +
+                "              schema:\n" +
+                "                type: object\n" +
+                "                properties:\n" +
+                "                  dateTime:\n" +
+                "                    $ref: '#/components/schemas/DateTimeString'\n" +
+                "components:\n" +
+                "  schemas:\n" +
+                "    DateTimeString:\n" +
+                "      type: string\n" +
+                "      format: date-time\n" +
+                "      default: 2019-01-01T00:00:00Z\n" +
+                "      enum:\n" +
+                "        - null\n" +
+                "        - Nunh uh\n" +
+                "        - 2019-01-01T00:00:00Z\n" +
+                "        - 2018-02-02T23:59:59.999-05:00\n" +
+                "        - 2017-03-03T11:22:33+06:00\n" +
+                "        - 2016-04-04T22:33:44.555Z\n" +
+                "";
+        OpenAPIV3Parser parser = new OpenAPIV3Parser();
+        SwaggerParseResult result = parser.readContents(yaml, null, null);
+
+        final OpenAPI resolved = new OpenAPIResolver(result.getOpenAPI(), null).resolve();
+
+        Schema dateTimeModel = resolved.getComponents().getSchemas().get("DateTimeString");
+        assertTrue(dateTimeModel instanceof DateTimeSchema);
+        List<Date> dateTimeValues = dateTimeModel.getEnum();
+        assertEquals(dateTimeValues.size(), 5);
+        assertEquals(
+          dateTimeValues.get(0),
+          null);
+        assertEquals(
+          dateTimeValues.get(1),
+          new Calendar.Builder()
+          .setDate( 2019, 0, 1)
+          .setTimeOfDay( 0, 0, 0, 0)
+          .setTimeZone( TimeZone.getTimeZone( "GMT"))
+          .build()
+          .getTime());
+        assertEquals(
+          dateTimeValues.get(2),
+          new Calendar.Builder()
+          .setDate( 2018, 1, 2)
+          .setTimeOfDay( 23, 59, 59, 999)
+          .setTimeZone( TimeZone.getTimeZone( "GMT-05:00"))
+          .build()
+          .getTime());
+        assertEquals(
+          dateTimeValues.get(3),
+          new Calendar.Builder()
+          .setDate( 2017, 2, 3)
+          .setTimeOfDay( 11, 22, 33, 0)
+          .setTimeZone( TimeZone.getTimeZone( "GMT+06:00"))
+          .build()
+          .getTime());
+        assertEquals(
+          dateTimeValues.get(4),
+          new Calendar.Builder()
+          .setDate( 2016, 3, 4)
+          .setTimeOfDay( 22, 33, 44, 555)
+          .setTimeZone( TimeZone.getTimeZone( "GMT"))
+          .build()
+          .getTime());
+
+        assertEquals(
+          dateTimeModel.getDefault(),
+          new Calendar.Builder()
+          .setDate( 2019, 0, 1)
+          .setTimeOfDay( 0, 0, 0, 0)
+          .setTimeZone( TimeZone.getTimeZone( "GMT"))
+          .build()
+          .getTime());
+
+        assertEquals(
+          result.getMessages(),
+          Arrays.asList( "attribute components.schemas.DateTimeString.enum=`Nunh uh` is not of type `date-time`"));
+    }
+
+    @Test
+    public void testDeserializeByteString() {
+        String yaml = "openapi: 3.0.0\n" +
+                "servers: []\n" +
+                "info:\n" +
+                "  version: 0.0.0\n" +
+                "  title: My Title\n" +
+                "paths:\n" +
+                "  /persons:\n" +
+                "    get:\n" +
+                "      description: a test\n" +
+                "      responses:\n" +
+                "        '200':\n" +
+                "          description: Successful response\n" +
+                "          content:\n" +
+                "            '*/*':\n" +
+                "              schema:\n" +
+                "                type: object\n" +
+                "                properties:\n" +
+                "                  bytes:\n" +
+                "                    $ref: '#/components/schemas/ByteString'\n" +
+                "components:\n" +
+                "  schemas:\n" +
+                "    ByteString:\n" +
+                "      type: string\n" +
+                "      format: byte\n" +
+                "      default: W.T.F?\n" +
+                "      enum:\n" +
+                "        - VGhlIHdvcmxk\n" +
+                "        - aXMgYWxs\n" +
+                "        - dGhhdCBpcw==\n" +
+                "        - dGhlIGNhc2U=\n" +
+                "        - W.T.F?\n" +
+                "";
+        OpenAPIV3Parser parser = new OpenAPIV3Parser();
+        SwaggerParseResult result = parser.readContents(yaml, null, null);
+
+        final OpenAPI resolved = new OpenAPIResolver(result.getOpenAPI(), null).resolve();
+
+        Schema byteModel = resolved.getComponents().getSchemas().get("ByteString");
+        assertTrue(byteModel instanceof ByteArraySchema);
+        List<byte[]> byteValues = byteModel.getEnum();
+        assertEquals(byteValues.size(), 4);
+        assertEquals(new String( byteValues.get(0)), "The world");
+        assertEquals(new String( byteValues.get(1)), "is all");
+        assertEquals(new String( byteValues.get(2)), "that is");
+        assertEquals(new String( byteValues.get(3)), "the case");
+
+        assertEquals( byteModel.getDefault(), null);
+
+        assertEquals(
+          result.getMessages(),
+          Arrays.asList(
+            "attribute components.schemas.ByteString.enum=`W.T.F?` is not of type `byte`",
+            "attribute components.schemas.ByteString.default=`W.T.F?` is not of type `byte`"));
+    }
+
     @Test
     public void testDeserializeWithMessages() {
         String yaml = "openapi: '3.0.0'\n" +