diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiResponse.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiResponse.java index a068e1ff..7e24fca7 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiResponse.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/HttpApiResponse.java @@ -4,7 +4,11 @@ import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.AcceptHeaderParser; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.api.response.ApiResponseAsDelimitedText; +import uk.co.compendiumdev.thingifier.api.response.ApiResponseAsHtml; import uk.co.compendiumdev.thingifier.api.response.ApiResponseAsJson; +import uk.co.compendiumdev.thingifier.api.response.ApiResponseAsJsonLines; +import uk.co.compendiumdev.thingifier.api.response.ApiResponseAsPlainText; import uk.co.compendiumdev.thingifier.api.response.ApiResponseAsXml; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; @@ -20,7 +24,7 @@ public final class HttpApiResponse { private final ThingifierApiConfig apiConfig; private String type; - private boolean asJson; + private AcceptHeaderParser.ACCEPT_TYPE responseType; public HttpApiResponse( final HttpHeadersBlock requestHeaders, @@ -31,7 +35,7 @@ public HttpApiResponse( this.apiResponseHeaders = new HttpHeadersBlock(); this.jsonThing = jsonThing; this.apiConfig = apiConfig; - asJson = true; + responseType = AcceptHeaderParser.ACCEPT_TYPE.JSON; HttpHeadersBlock useRequestHeaders = requestHeaders == null ? new HttpHeadersBlock() : requestHeaders; @@ -49,22 +53,8 @@ private void configureFrom( AcceptHeaderParser accept = new AcceptHeaderParser(acceptHeader); - if (accept.hasAPreferenceForXml()) { - if (apiConfig.willApiAllowXmlForResponses()) { - asJson = false; - } - } - - if (!apiConfig.willApiAllowJsonForResponses()) { - asJson = false; - } - - // TODO: handle text/plain, text/html - if (asJson) { - type = "application/json"; - } else { - type = "application/xml"; - } + responseType = selectResponseType(accept); + type = responseType.mediaType(); apiResponseHeaders.putAll(originalApiResponseHeaders); apiResponseHeaders.put("Content-Type", type); @@ -74,16 +64,61 @@ private void configureFrom( } } - // TODO: handle text/plain, text/html + private AcceptHeaderParser.ACCEPT_TYPE selectResponseType(final AcceptHeaderParser accept) { + for (AcceptHeaderParser.ACCEPT_TYPE candidate : + accept.getSupportedTypesInPreferenceOrder()) { + if (candidate == AcceptHeaderParser.ACCEPT_TYPE.ANYTHING) { + continue; + } + if (canRender(candidate)) { + return candidate; + } + } + return defaultResponseType(); + } + + private boolean canRender(final AcceptHeaderParser.ACCEPT_TYPE candidate) { + if (candidate == AcceptHeaderParser.ACCEPT_TYPE.XML) { + return apiConfig.willApiAllowXmlForResponses(); + } + if (candidate == AcceptHeaderParser.ACCEPT_TYPE.JSON) { + return apiConfig.willApiAllowJsonForResponses(); + } + return candidate != AcceptHeaderParser.ACCEPT_TYPE.NO_MATCHING_TYPE; + } + + private AcceptHeaderParser.ACCEPT_TYPE defaultResponseType() { + if (apiConfig.willApiAllowJsonForResponses()) { + return AcceptHeaderParser.ACCEPT_TYPE.JSON; + } + return AcceptHeaderParser.ACCEPT_TYPE.XML; + } + public String getBody() { if (apiResponse.hasABodyOverride()) { return apiResponse.getBody(); } - if (asJson) { - return new ApiResponseAsJson(apiResponse, jsonThing).getJson(); - } - return new ApiResponseAsXml(apiResponse, jsonThing).getXml(); + switch (responseType) { + case XML: + return new ApiResponseAsXml(apiResponse, jsonThing).getXml(); + case CSV: + return new ApiResponseAsDelimitedText(apiResponse, ',').getText(); + case TEXT: + return new ApiResponseAsPlainText(apiResponse).getText(); + case HTML: + return new ApiResponseAsHtml(apiResponse).getHtml(); + case NDJSON: + case JSONL: + return new ApiResponseAsJsonLines(apiResponse, jsonThing).getJsonLines(); + case JSON_SEQ: + return new ApiResponseAsJsonLines(apiResponse, jsonThing).getJsonSequence(); + case TSV: + return new ApiResponseAsDelimitedText(apiResponse, '\t').getText(); + case JSON: + default: + return new ApiResponseAsJson(apiResponse, jsonThing).getJson(); + } } public boolean hasType() { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headerparser/AcceptHeaderParser.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headerparser/AcceptHeaderParser.java index 4dc40e59..b9d6176d 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headerparser/AcceptHeaderParser.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headerparser/AcceptHeaderParser.java @@ -2,20 +2,9 @@ import java.util.*; -// TODO: configure with additional accept strings e.g. to allow text/plain public class AcceptHeaderParser { private final String acceptHeader; private final List acceptMediaTypeDefinitionsList; - private final String[] acceptedXmlStrings = { - "application/xml", - }; - private final String[] acceptedJsonStrings = {"application/json"}; - - private final String[] acceptedAnythingStrings = {"application/*", "*/*"}; - - private final String[] acceptedTextStrings = {"text/plain", "text/html"}; - - private final Map> acceptedTypes; public boolean willAcceptAnything() { return willAccept(ACCEPT_TYPE.ANYTHING); @@ -33,6 +22,30 @@ public boolean willAcceptText() { return willAccept(ACCEPT_TYPE.TEXT); } + public boolean willAcceptCsv() { + return willAccept(ACCEPT_TYPE.CSV); + } + + public boolean willAcceptHtml() { + return willAccept(ACCEPT_TYPE.HTML); + } + + public boolean willAcceptNdJson() { + return willAccept(ACCEPT_TYPE.NDJSON); + } + + public boolean willAcceptJsonLines() { + return willAccept(ACCEPT_TYPE.JSONL); + } + + public boolean willAcceptJsonSequence() { + return willAccept(ACCEPT_TYPE.JSON_SEQ); + } + + public boolean willAcceptTsv() { + return willAccept(ACCEPT_TYPE.TSV); + } + public boolean hasAskedForXML() { return hasAskedFor(AcceptHeaderParser.ACCEPT_TYPE.XML); } @@ -49,6 +62,30 @@ public boolean hasAskedForTEXT() { return hasAskedFor(ACCEPT_TYPE.TEXT); } + public boolean hasAskedForCSV() { + return hasAskedFor(ACCEPT_TYPE.CSV); + } + + public boolean hasAskedForHTML() { + return hasAskedFor(ACCEPT_TYPE.HTML); + } + + public boolean hasAskedForNDJSON() { + return hasAskedFor(ACCEPT_TYPE.NDJSON); + } + + public boolean hasAskedForJSONL() { + return hasAskedFor(ACCEPT_TYPE.JSONL); + } + + public boolean hasAskedForJSONSEQ() { + return hasAskedFor(ACCEPT_TYPE.JSON_SEQ); + } + + public boolean hasAskedForTSV() { + return hasAskedFor(ACCEPT_TYPE.TSV); + } + public boolean missingAcceptHeader() { return this.acceptHeader.length() == 0; } @@ -70,11 +107,46 @@ public boolean isSupportedHeader() { } public enum ACCEPT_TYPE { - XML, - JSON, - ANYTHING, - NO_MATCHING_TYPE, - TEXT + XML("application/xml"), + JSON("application/json"), + CSV("text/csv"), + TEXT("text/plain"), + HTML("text/html"), + NDJSON("application/x-ndjson"), + JSONL("application/jsonl"), + JSON_SEQ("application/json-seq"), + TSV("text/tab-separated-values"), + ANYTHING("application/*", "*/*"), + NO_MATCHING_TYPE(); + + private final List mediaTypes; + + ACCEPT_TYPE(final String... mediaTypes) { + this.mediaTypes = List.of(mediaTypes); + } + + public String mediaType() { + if (mediaTypes.isEmpty()) { + return ""; + } + return mediaTypes.get(0); + } + + public List mediaTypes() { + return mediaTypes; + } + + public boolean hasConcreteResponseMediaType() { + return this != ANYTHING && this != NO_MATCHING_TYPE; + } + + public boolean usesComponentSchemaInDocumentation() { + return this == JSON || this == XML; + } + + public static List responseMediaTypes() { + return List.of(JSON, XML, CSV, TEXT, HTML, NDJSON, JSONL, JSON_SEQ, TSV); + } }; // TODO: configure to all new accept headers and remove accept headers @@ -87,13 +159,6 @@ public AcceptHeaderParser(final String acceptHeader) { this.acceptHeader = acceptHeader.trim().toLowerCase(); } - acceptedTypes = new HashMap>(); - acceptedTypes.put(ACCEPT_TYPE.XML, Arrays.asList(acceptedXmlStrings)); - acceptedTypes.put(ACCEPT_TYPE.JSON, Arrays.asList(acceptedJsonStrings)); - acceptedTypes.put(ACCEPT_TYPE.ANYTHING, Arrays.asList(acceptedAnythingStrings)); - acceptedTypes.put(ACCEPT_TYPE.NO_MATCHING_TYPE, new ArrayList<>()); - acceptedTypes.put(ACCEPT_TYPE.TEXT, Arrays.asList(acceptedTextStrings)); - // TODO: use ;q=0.9 to sort items in the array String[] acceptMediaTypeDefinitions = this.acceptHeader.split(","); acceptMediaTypeDefinitionsList = new ArrayList<>(); @@ -126,18 +191,36 @@ public boolean hasAPreferenceFor(final ACCEPT_TYPE type) { return false; } + public List getSupportedTypesInPreferenceOrder() { + List supportedTypes = new ArrayList<>(); + for (String acceptedType : acceptMediaTypeDefinitionsList) { + ACCEPT_TYPE matchingType = getMatchingType(acceptedType); + if (matchingType != ACCEPT_TYPE.NO_MATCHING_TYPE) { + supportedTypes.add(matchingType); + } + } + return supportedTypes; + } + private ACCEPT_TYPE getMatchingType(final String matchMe) { - for (Map.Entry> type : acceptedTypes.entrySet()) { - List validMatches = type.getValue(); - for (String possibleMatch : validMatches) { - if (matchMe.contains(possibleMatch)) { - return type.getKey(); + final String mediaType = mediaTypeFrom(matchMe); + for (ACCEPT_TYPE type : ACCEPT_TYPE.values()) { + for (String possibleMatch : type.mediaTypes()) { + if (mediaType.equals(possibleMatch)) { + return type; } } } return ACCEPT_TYPE.NO_MATCHING_TYPE; } + private String mediaTypeFrom(final String acceptMediaTypeDefinition) { + if (acceptMediaTypeDefinition == null) { + return ""; + } + return acceptMediaTypeDefinition.split(";", 2)[0].trim(); + } + public boolean hasAPreferenceForXml() { return hasAPreferenceFor(ACCEPT_TYPE.XML); } @@ -146,6 +229,34 @@ public boolean hasAPreferenceForJson() { return hasAPreferenceFor(ACCEPT_TYPE.JSON); } + public boolean hasAPreferenceForCsv() { + return hasAPreferenceFor(ACCEPT_TYPE.CSV); + } + + public boolean hasAPreferenceForText() { + return hasAPreferenceFor(ACCEPT_TYPE.TEXT); + } + + public boolean hasAPreferenceForHtml() { + return hasAPreferenceFor(ACCEPT_TYPE.HTML); + } + + public boolean hasAPreferenceForNdJson() { + return hasAPreferenceFor(ACCEPT_TYPE.NDJSON); + } + + public boolean hasAPreferenceForJsonLines() { + return hasAPreferenceFor(ACCEPT_TYPE.JSONL); + } + + public boolean hasAPreferenceForJsonSequence() { + return hasAPreferenceFor(ACCEPT_TYPE.JSON_SEQ); + } + + public boolean hasAPreferenceForTsv() { + return hasAPreferenceFor(ACCEPT_TYPE.TSV); + } + public boolean willAccept(final ACCEPT_TYPE type) { // if no types provided then we will accept anything @@ -163,12 +274,11 @@ public boolean willAccept(final ACCEPT_TYPE type) { } public boolean hasAskedFor(final ACCEPT_TYPE type) { - List typeValues = acceptedTypes.get(type); - // look for specific type for (String acceptedType : acceptMediaTypeDefinitionsList) { - for (String typeValue : typeValues) { - if (acceptedType.contains(typeValue)) { + String mediaType = mediaTypeFrom(acceptedType); + for (String typeValue : type.mediaTypes()) { + if (mediaType.equals(typeValue)) { return true; } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headervalidator/AcceptHeaderValidator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headervalidator/AcceptHeaderValidator.java index fc7f75fc..272a3310 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headervalidator/AcceptHeaderValidator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/headers/headervalidator/AcceptHeaderValidator.java @@ -1,5 +1,6 @@ package uk.co.compendiumdev.thingifier.api.http.headers.headervalidator; +import java.util.List; import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.AcceptHeaderParser; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; @@ -24,22 +25,50 @@ public ApiResponse validate(final String acceptHeader) { } } - boolean willOnlyAcceptXML = - accept.hasAskedFor(AcceptHeaderParser.ACCEPT_TYPE.XML) && !accept.willAcceptJson(); - if (willOnlyAcceptXML - && !this.apiConfig.willApiAllowXmlForResponses() - && this.apiConfig.willApiEnforceAcceptHeaderForResponses()) { - apiResponse = ApiResponse.error(statusAcceptTypeNotSupported, "XML not supported"); + if (apiResponse == null && this.apiConfig.willApiEnforceAcceptHeaderForResponses()) { + if (!hasAnyAllowedResponseType(accept)) { + apiResponse = + ApiResponse.error( + statusAcceptTypeNotSupported, + unsupportedResponseTypeMessage(accept)); + } } - boolean willOnlyAcceptJSON = - accept.hasAskedFor(AcceptHeaderParser.ACCEPT_TYPE.JSON) && !accept.willAcceptXml(); - if (willOnlyAcceptJSON - && !this.apiConfig.willApiAllowJsonForResponses() - && this.apiConfig.willApiEnforceAcceptHeaderForResponses()) { - apiResponse = ApiResponse.error(statusAcceptTypeNotSupported, "JSON not supported"); + return apiResponse; + } + + private boolean hasAnyAllowedResponseType(final AcceptHeaderParser accept) { + final List supportedTypes = + accept.getSupportedTypesInPreferenceOrder(); + if (supportedTypes.isEmpty()) { + return true; } - return apiResponse; + for (AcceptHeaderParser.ACCEPT_TYPE supportedType : supportedTypes) { + if (supportedType == AcceptHeaderParser.ACCEPT_TYPE.ANYTHING) { + return true; + } + if (supportedType == AcceptHeaderParser.ACCEPT_TYPE.XML + && !this.apiConfig.willApiAllowXmlForResponses()) { + continue; + } + if (supportedType == AcceptHeaderParser.ACCEPT_TYPE.JSON + && !this.apiConfig.willApiAllowJsonForResponses()) { + continue; + } + return true; + } + + return false; + } + + private String unsupportedResponseTypeMessage(final AcceptHeaderParser accept) { + if (accept.hasAskedFor(AcceptHeaderParser.ACCEPT_TYPE.XML) && !accept.willAcceptJson()) { + return "XML not supported"; + } + if (accept.hasAskedFor(AcceptHeaderParser.ACCEPT_TYPE.JSON) && !accept.willAcceptXml()) { + return "JSON not supported"; + } + return "No acceptable response type supported"; } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsDelimitedText.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsDelimitedText.java new file mode 100644 index 00000000..35c5d05a --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsDelimitedText.java @@ -0,0 +1,70 @@ +package uk.co.compendiumdev.thingifier.api.response; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + +public final class ApiResponseAsDelimitedText { + + private final ApiResponse apiResponse; + private final char delimiter; + + public ApiResponseAsDelimitedText(final ApiResponse apiResponse, final char delimiter) { + this.apiResponse = apiResponse; + this.delimiter = delimiter; + } + + public String getText() { + if (!apiResponse.hasABody()) { + return ""; + } + + if (apiResponse.isErrorResponse()) { + return errorMessages(); + } + + ApiResponseBodyRows responseRows = new ApiResponseBodyRows(apiResponse); + List lines = new ArrayList<>(); + lines.add(delimitedLine(responseRows.fieldNames())); + for (List row : responseRows.rows()) { + lines.add(delimitedLine(row)); + } + return String.join("\n", lines); + } + + private String errorMessages() { + List lines = new ArrayList<>(); + lines.add(escape("errorMessage")); + for (String message : apiResponse.getErrorMessages()) { + lines.add(escape(message)); + } + return String.join("\n", lines); + } + + private String delimitedLine(final List values) { + StringJoiner joiner = new StringJoiner(String.valueOf(delimiter)); + for (String value : values) { + joiner.add(escape(value)); + } + return joiner.toString(); + } + + private String escape(final String value) { + String safeValue = value == null ? "" : value; + if (delimiter == '\t') { + return safeValue + .replace("\\", "\\\\") + .replace("\t", "\\t") + .replace("\r", "\\r") + .replace("\n", "\\n"); + } + + if (safeValue.contains("\"") + || safeValue.contains(",") + || safeValue.contains("\r") + || safeValue.contains("\n")) { + return "\"" + safeValue.replace("\"", "\"\"") + "\""; + } + return safeValue; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsHtml.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsHtml.java new file mode 100644 index 00000000..5343af8f --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsHtml.java @@ -0,0 +1,61 @@ +package uk.co.compendiumdev.thingifier.api.response; + +import java.util.List; + +public final class ApiResponseAsHtml { + + private final ApiResponse apiResponse; + + public ApiResponseAsHtml(final ApiResponse apiResponse) { + this.apiResponse = apiResponse; + } + + public String getHtml() { + if (!apiResponse.hasABody()) { + return ""; + } + + if (apiResponse.isErrorResponse()) { + return errorMessages(); + } + + ApiResponseBodyRows responseRows = new ApiResponseBodyRows(apiResponse); + List fieldNames = responseRows.fieldNames(); + StringBuilder html = new StringBuilder(); + html.append(""); + for (String fieldName : fieldNames) { + html.append(""); + } + html.append(""); + for (List row : responseRows.rows()) { + html.append(""); + for (String value : row) { + html.append(""); + } + html.append(""); + } + html.append("
").append(escape(fieldName)).append("
").append(escape(value)).append("
"); + return html.toString(); + } + + private String errorMessages() { + StringBuilder html = new StringBuilder(); + html.append("
    "); + for (String message : apiResponse.getErrorMessages()) { + html.append("
  • ").append(escape(message)).append("
  • "); + } + html.append("
"); + return html.toString(); + } + + private String escape(final String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsJsonLines.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsJsonLines.java new file mode 100644 index 00000000..6af0d86f --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsJsonLines.java @@ -0,0 +1,81 @@ +package uk.co.compendiumdev.thingifier.api.response; + +import com.google.gson.JsonObject; +import java.util.ArrayList; +import java.util.List; +import uk.co.compendiumdev.thingifier.api.ermodelconversion.JsonThing; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; + +public final class ApiResponseAsJsonLines { + + private static final String RECORD_SEPARATOR = "\u001E"; + + private final ApiResponse apiResponse; + private final JsonThing jsonThing; + + public ApiResponseAsJsonLines(final ApiResponse apiResponse, final JsonThing jsonThing) { + this.apiResponse = apiResponse; + this.jsonThing = jsonThing; + } + + public String getJsonLines() { + return String.join("\n", jsonObjects()); + } + + public String getJsonSequence() { + List jsonObjects = jsonObjects(); + if (jsonObjects.isEmpty()) { + return ""; + } + + StringBuilder sequence = new StringBuilder(); + for (String jsonObject : jsonObjects) { + sequence.append(RECORD_SEPARATOR).append(jsonObject).append("\n"); + } + return sequence.toString(); + } + + private List jsonObjects() { + List objects = new ArrayList<>(); + if (!apiResponse.hasABody()) { + return objects; + } + + if (apiResponse.isErrorResponse()) { + for (String message : apiResponse.getErrorMessages()) { + JsonObject error = new JsonObject(); + error.addProperty("errorMessage", message); + objects.add(error.toString()); + } + return objects; + } + + if (apiResponse.isCollection()) { + for (EntityInstance instance : apiResponse.getReturnedInstanceCollection()) { + objects.add(jsonFor(instance)); + } + return objects; + } + + if (apiResponse.hasReturnedDraft()) { + objects.add( + jsonThing + .asJsonObject( + apiResponse.getReturnedDraft(), apiResponse.getResponseView()) + .toString()); + return objects; + } + + objects.add(jsonFor(apiResponse.getReturnedInstance())); + return objects; + } + + private String jsonFor(final EntityInstance instance) { + return jsonThing + .asJsonObject( + instance, + apiResponse.getRelationshipRepository(), + apiResponse.getResponseView()) + .toString(); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsPlainText.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsPlainText.java new file mode 100644 index 00000000..90b01d3b --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsPlainText.java @@ -0,0 +1,50 @@ +package uk.co.compendiumdev.thingifier.api.response; + +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; + +public final class ApiResponseAsPlainText { + + private final ApiResponse apiResponse; + + public ApiResponseAsPlainText(final ApiResponse apiResponse) { + this.apiResponse = apiResponse; + } + + public String getText() { + if (!apiResponse.hasABody()) { + return ""; + } + + if (apiResponse.isErrorResponse()) { + return String.join("\n", escapedMessages()); + } + + ApiResponseBodyRows responseRows = new ApiResponseBodyRows(apiResponse); + List fieldNames = responseRows.fieldNames(); + List lines = new ArrayList<>(); + for (List row : responseRows.rows()) { + StringJoiner joiner = new StringJoiner(", "); + for (int index = 0; index < fieldNames.size(); index++) { + joiner.add(fieldNames.get(index) + "=" + escape(row.get(index))); + } + lines.add(joiner.toString()); + } + return String.join("\n", lines); + } + + private List escapedMessages() { + List messages = new ArrayList<>(); + for (String message : apiResponse.getErrorMessages()) { + messages.add(escape(message)); + } + return messages; + } + + private String escape(final String value) { + return value == null + ? "" + : value.replace("\\", "\\\\").replace("\r", "\\r").replace("\n", "\\n"); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseBodyRows.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseBodyRows.java new file mode 100644 index 00000000..9df7a007 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseBodyRows.java @@ -0,0 +1,123 @@ +package uk.co.compendiumdev.thingifier.api.response; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.FieldValue; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.NamedValue; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; + +final class ApiResponseBodyRows { + + private final ApiResponse apiResponse; + + ApiResponseBodyRows(final ApiResponse apiResponse) { + this.apiResponse = apiResponse; + } + + public List fieldNames() { + List names = new ArrayList<>(); + EntityDefinition entity = entityDefinition(); + if (entity == null) { + return names; + } + + EntityViewDefinition view = apiResponse.getResponseView(); + for (String fieldName : entity.getFieldNames()) { + if (view != null && !view.isResponseVisible(fieldName)) { + continue; + } + + Field field = entity.getField(fieldName); + if (field.getType() == FieldType.OBJECT) { + continue; + } + names.add(fieldName); + } + return names; + } + + public List> rows() { + List> rows = new ArrayList<>(); + if (!apiResponse.hasABody() || apiResponse.isErrorResponse()) { + return rows; + } + + List fieldNames = fieldNames(); + if (apiResponse.isCollection()) { + for (EntityInstance instance : apiResponse.getReturnedInstanceCollection()) { + rows.add(rowFor(instance, fieldNames)); + } + return rows; + } + + if (apiResponse.hasReturnedDraft()) { + rows.add(rowFor(apiResponse.getReturnedDraft(), fieldNames)); + return rows; + } + + rows.add(rowFor(apiResponse.getReturnedInstance(), fieldNames)); + return rows; + } + + private EntityDefinition entityDefinition() { + if (apiResponse.getTypeOfThingReturned() != null) { + return apiResponse.getTypeOfThingReturned(); + } + if (!apiResponse.hasABody() || apiResponse.isErrorResponse()) { + return null; + } + if (apiResponse.isCollection()) { + List instances = apiResponse.getReturnedInstanceCollection(); + if (!instances.isEmpty()) { + return instances.get(0).getEntity(); + } + return null; + } + if (apiResponse.hasReturnedDraft()) { + return apiResponse.getReturnedDraft().getEntity(); + } + return apiResponse.getReturnedInstance().getEntity(); + } + + private List rowFor(final EntityInstance instance, final List fieldNames) { + List row = new ArrayList<>(); + for (String fieldName : fieldNames) { + FieldValue value = instance.getFieldValue(fieldName); + row.add(value == null ? "" : value.asString()); + } + return row; + } + + private List rowFor(final EntityInstanceDraft draft, final List fieldNames) { + List row = new ArrayList<>(); + Map values = new HashMap<>(); + for (NamedValue value : draft.getFieldValues()) { + values.put(value.getName().toLowerCase(), value.asString()); + } + for (NamedValue value : draft.getProtectedFieldValues()) { + values.put(value.getName().toLowerCase(), value.asString()); + } + + EntityDefinition entity = draft.getEntity(); + for (String fieldName : fieldNames) { + Field field = entity.getField(fieldName); + String value = values.get(fieldName.toLowerCase()); + if (value == null) { + if (field.hasDefaultValue()) { + value = field.getDefaultValue().asString(); + } else { + value = field.getType().getDefault(); + } + } + row.add(value == null ? "" : value); + } + return row; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java index 659880cc..17854190 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java @@ -14,6 +14,7 @@ import uk.co.compendiumdev.thingifier.api.ermodelconversion.XmlThing; import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; import uk.co.compendiumdev.thingifier.api.http.bodyparser.xml.GenericXMLPrettyPrinter; +import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.AcceptHeaderParser; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; @@ -130,7 +131,10 @@ public String getApiDocumentation( paragraph( "You can request XML response by setting the Accept header.")); output.append(paragraph("i.e. for XML use")); - output.append(paragraph("Accept: application/xml

\n")); + output.append( + paragraph( + acceptHeaderExample(AcceptHeaderParser.ACCEPT_TYPE.XML) + + "

\n")); } if (!thingifier.apiConfig().willApiAllowXmlForResponses() @@ -143,9 +147,15 @@ public String getApiDocumentation( paragraph( "You can request JSON response by setting the Accept header.")); output.append(paragraph("i.e. for JSON use")); - output.append(paragraph("Accept: application/json

\n")); + output.append( + paragraph( + acceptHeaderExample(AcceptHeaderParser.ACCEPT_TYPE.JSON) + + "

\n")); } + output.append(paragraph("Additional response Accept headers are supported.")); + output.append(paragraph(additionalResponseAcceptHeaders())); + if (thingifier.apiConfig().forParams().willAllowFilteringThroughUrlParams()) { Collection defns = @@ -936,6 +946,24 @@ private String href(final String text, final String url) { return String.format("%s", url, text); } + private String additionalResponseAcceptHeaders() { + StringBuilder headers = new StringBuilder(); + for (AcceptHeaderParser.ACCEPT_TYPE responseType : + AcceptHeaderParser.ACCEPT_TYPE.responseMediaTypes()) { + if (responseType == AcceptHeaderParser.ACCEPT_TYPE.JSON + || responseType == AcceptHeaderParser.ACCEPT_TYPE.XML) { + continue; + } + headers.append(acceptHeaderExample(responseType)).append("
\n"); + } + headers.append("
\n"); + return headers.toString(); + } + + private String acceptHeaderExample(final AcceptHeaderParser.ACCEPT_TYPE responseType) { + return String.format("Accept: %s", responseType.mediaType()); + } + private String paragraph(final String initialParagraph) { return String.format("

%s

%n", initialParagraph); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java index 544414f8..2dbb33b8 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java @@ -25,6 +25,7 @@ import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; +import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.AcceptHeaderParser; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; @@ -198,17 +199,7 @@ && hasVisibleRouteForUrl(routes, route.url(), config)) { + subroute.getReturnPayloadFor( possibleStatus.value()); - Schema object = new Schema<>(); - MediaType schema = new MediaType(); - schema.setSchema(object); - object.set$ref(ref); - - response.setContent( - new Content() - .addMediaType( - "application/json", schema) - .addMediaType( - "application/xml", schema)); + response.setContent(responseContentWith(ref)); } } @@ -567,6 +558,26 @@ private void addRouteResponseHeaders( } } + private Content responseContentWith(final String ref) { + Schema object = new Schema<>(); + MediaType schema = new MediaType(); + schema.setSchema(object); + object.set$ref(ref); + + Content content = new Content(); + for (AcceptHeaderParser.ACCEPT_TYPE responseType : + AcceptHeaderParser.ACCEPT_TYPE.responseMediaTypes()) { + if (responseType.usesComponentSchemaInDocumentation()) { + content.addMediaType(responseType.mediaType(), schema); + } else { + MediaType textSchema = new MediaType(); + textSchema.setSchema(new StringSchema()); + content.addMediaType(responseType.mediaType(), textSchema); + } + } + return content; + } + private void addHttpSecurityScheme( final Components components, final String name, diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/AcceptHeaderParserTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/AcceptHeaderParserTest.java index 0c1b434d..b97c33f3 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/AcceptHeaderParserTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/AcceptHeaderParserTest.java @@ -1,5 +1,6 @@ package uk.co.compendiumdev.thingifier.api; +import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.AcceptHeaderParser; @@ -150,4 +151,54 @@ public void hasNotAskedForAnythingWillAcceptDefault() { Assertions.assertFalse(accept.hasAskedFor(AcceptHeaderParser.ACCEPT_TYPE.XML)); Assertions.assertFalse(accept.hasAskedFor(AcceptHeaderParser.ACCEPT_TYPE.JSON)); } + + @Test + public void willAcceptAdditionalResponseRepresentations() { + Assertions.assertTrue( + new AcceptHeaderParser("text/csv").willAccept(AcceptHeaderParser.ACCEPT_TYPE.CSV)); + Assertions.assertTrue(new AcceptHeaderParser("text/plain").willAcceptText()); + Assertions.assertTrue( + new AcceptHeaderParser("text/html") + .willAccept(AcceptHeaderParser.ACCEPT_TYPE.HTML)); + Assertions.assertTrue( + new AcceptHeaderParser("application/x-ndjson") + .willAccept(AcceptHeaderParser.ACCEPT_TYPE.NDJSON)); + Assertions.assertTrue( + new AcceptHeaderParser("application/jsonl") + .willAccept(AcceptHeaderParser.ACCEPT_TYPE.JSONL)); + Assertions.assertTrue( + new AcceptHeaderParser("application/json-seq") + .willAccept(AcceptHeaderParser.ACCEPT_TYPE.JSON_SEQ)); + Assertions.assertTrue( + new AcceptHeaderParser("text/tab-separated-values") + .willAccept(AcceptHeaderParser.ACCEPT_TYPE.TSV)); + } + + @Test + public void matchesMediaTypesBeforeHeaderParameters() { + final AcceptHeaderParser accept = + new AcceptHeaderParser("application/json-seq; charset=utf-8"); + + Assertions.assertTrue(accept.hasAskedFor(AcceptHeaderParser.ACCEPT_TYPE.JSON_SEQ)); + Assertions.assertFalse(accept.hasAskedFor(AcceptHeaderParser.ACCEPT_TYPE.JSON)); + } + + @Test + public void identifiesFirstSupportedRepresentationInHeaderOrder() { + final AcceptHeaderParser accept = + new AcceptHeaderParser("application/unknown, text/html, text/csv"); + + Assertions.assertEquals( + List.of(AcceptHeaderParser.ACCEPT_TYPE.HTML, AcceptHeaderParser.ACCEPT_TYPE.CSV), + accept.getSupportedTypesInPreferenceOrder()); + Assertions.assertTrue(accept.hasAPreferenceForHtml()); + Assertions.assertFalse(accept.hasAPreferenceForCsv()); + } + + @Test + public void textWildcardIsNotASupportedResponseRepresentation() { + final AcceptHeaderParser accept = new AcceptHeaderParser("text/*"); + + Assertions.assertFalse(accept.isSupportedHeader()); + } } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/headers/headervalidator/AcceptHeaderValidatorTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/headers/headervalidator/AcceptHeaderValidatorTest.java new file mode 100644 index 00000000..fc73f427 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/headers/headervalidator/AcceptHeaderValidatorTest.java @@ -0,0 +1,54 @@ +package uk.co.compendiumdev.thingifier.api.http.headers.headervalidator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; + +class AcceptHeaderValidatorTest { + + @Test + void acceptsAdditionalResponseRepresentations() { + ThingifierApiConfig config = new ThingifierApiConfig(""); + AcceptHeaderValidator validator = new AcceptHeaderValidator(config); + + Assertions.assertNull(validator.validate("text/csv")); + Assertions.assertNull(validator.validate("text/plain")); + Assertions.assertNull(validator.validate("text/html")); + Assertions.assertNull(validator.validate("application/x-ndjson")); + Assertions.assertNull(validator.validate("application/jsonl")); + Assertions.assertNull(validator.validate("application/json-seq")); + Assertions.assertNull(validator.validate("text/tab-separated-values")); + } + + @Test + void rejectsUnsupportedHeaderWhenEnforced() { + ThingifierApiConfig config = new ThingifierApiConfig(""); + + ApiResponse response = new AcceptHeaderValidator(config).validate("text/*"); + + Assertions.assertEquals(406, response.getStatusCode()); + Assertions.assertTrue(response.getErrorMessages().contains("Unrecognised Accept Type")); + } + + @Test + void canFallThroughDisabledXmlToAdditionalRepresentation() { + ThingifierApiConfig config = new ThingifierApiConfig(""); + config.setApiToAllowXmlForResponses(false); + + ApiResponse response = + new AcceptHeaderValidator(config).validate("application/xml, text/csv"); + + Assertions.assertNull(response); + } + + @Test + void additionalJsonRepresentationsAreNotDisabledByJsonResponseConfig() { + ThingifierApiConfig config = new ThingifierApiConfig(""); + config.setApiToAllowJsonForResponses(false); + + ApiResponse response = new AcceptHeaderValidator(config).validate("application/json-seq"); + + Assertions.assertNull(response); + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/requests/ThingifierHttpApiAdditionalRepresentationsTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/requests/ThingifierHttpApiAdditionalRepresentationsTest.java new file mode 100644 index 00000000..6ebc2bea --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/requests/ThingifierHttpApiAdditionalRepresentationsTest.java @@ -0,0 +1,87 @@ +package uk.co.compendiumdev.thingifier.api.http.requests; + +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.AUTO_INCREMENT; +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.STRING; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; +import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse; +import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; + +class ThingifierHttpApiAdditionalRepresentationsTest { + + @Test + void getSelectsAdditionalResponseRepresentationsFromAcceptHeader() { + Thingifier thingifier = taskThingifier(); + createTask(thingifier, "Task"); + ThingifierHttpApi api = new ThingifierHttpApi(thingifier); + + for (Map.Entry expected : expectedBodies().entrySet()) { + HttpApiResponse response = + api.get(new HttpApiRequest("tasks").addHeader("Accept", expected.getKey())); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals(expected.getKey(), response.getType()); + Assertions.assertEquals(expected.getValue(), response.getBody()); + } + } + + @Test + void querySelectsAdditionalResponseRepresentationsFromAcceptHeader() { + Thingifier thingifier = taskThingifier(); + createTask(thingifier, "Task"); + ThingifierHttpApi api = new ThingifierHttpApi(thingifier); + + for (Map.Entry expected : expectedBodies().entrySet()) { + HttpApiRequest request = + new HttpApiRequest("tasks") + .addHeader("Content-Type", ThingifierHttpApi.QUERY_CONTENT_TYPE) + .addHeader("Accept", expected.getKey()) + .setBody("title=Task"); + HttpApiResponse response = api.queryRequest(request); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals(expected.getKey(), response.getType()); + Assertions.assertEquals(expected.getValue(), response.getBody()); + } + } + + private Map expectedBodies() { + Map expectedBodies = new LinkedHashMap<>(); + expectedBodies.put("text/csv", "id,title\n1,Task"); + expectedBodies.put("text/plain", "id=1, title=Task"); + expectedBodies.put( + "text/html", + "" + + "
idtitle
1Task
"); + expectedBodies.put("application/x-ndjson", "{\"id\":1,\"title\":\"Task\"}"); + expectedBodies.put("application/jsonl", "{\"id\":1,\"title\":\"Task\"}"); + expectedBodies.put("application/json-seq", "\u001E{\"id\":1,\"title\":\"Task\"}\n"); + expectedBodies.put("text/tab-separated-values", "id\ttitle\n1\tTask"); + return expectedBodies; + } + + private Thingifier taskThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("id", AUTO_INCREMENT)); + task.addField(Field.is("title", STRING)); + return thingifier; + } + + private void createTask(final Thingifier thingifier, final String title) { + EntityDefinition task = thingifier.getDefinitionNamed("task"); + thingifier + .getStore(EntityRelModel.DEFAULT_DATABASE_NAME) + .entities() + .create(EntityInstanceDraft.forEntity(task).withField("title", title)); + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAdditionalRepresentationsTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAdditionalRepresentationsTest.java new file mode 100644 index 00000000..b360515f --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAdditionalRepresentationsTest.java @@ -0,0 +1,184 @@ +package uk.co.compendiumdev.thingifier.api.response; + +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.AUTO_INCREMENT; +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.OBJECT; +import static uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType.STRING; + +import java.util.ArrayList; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.ermodelconversion.JsonThing; +import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; + +class ApiResponseAdditionalRepresentationsTest { + + private final JsonThing jsonThing = new JsonThing(new ThingifierApiConfig("").jsonOutput()); + + @Test + void csvUsesVisibleScalarFieldsAndQuotesValues() { + Thingifier thingifier = taskThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance instance = createTask(thingifier, "Hello, \"Codex\"", "Simple", "secret"); + + ApiResponse response = + ApiResponse.success() + .returnSingleInstance(instance) + .usingEntityView(task.getViewNamed("public")); + + Assertions.assertEquals( + "id,title,notes\n1,\"Hello, \"\"Codex\"\"\",Simple", + new ApiResponseAsDelimitedText(response, ',').getText()); + } + + @Test + void tsvEscapesTabsAndLineBreaks() { + Thingifier thingifier = taskThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance instance = createTask(thingifier, "Hello", "Tabbed\tLine\nNext", "secret"); + + ApiResponse response = + ApiResponse.success() + .returnSingleInstance(instance) + .usingEntityView(task.getViewNamed("public")); + + Assertions.assertEquals( + "id\ttitle\tnotes\n1\tHello\tTabbed\\tLine\\nNext", + new ApiResponseAsDelimitedText(response, '\t').getText()); + } + + @Test + void plainTextUsesOneEntityPerLine() { + Thingifier thingifier = taskThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance instance = createTask(thingifier, "Hello", "Simple", "secret"); + + ApiResponse response = + ApiResponse.success() + .returnSingleInstance(instance) + .usingEntityView(task.getViewNamed("public")); + + Assertions.assertEquals( + "id=1, title=Hello, notes=Simple", new ApiResponseAsPlainText(response).getText()); + } + + @Test + void htmlEscapesFieldValues() { + Thingifier thingifier = taskThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance instance = createTask(thingifier, "", "'note'", "secret"); + + ApiResponse response = + ApiResponse.success() + .returnSingleInstance(instance) + .usingEntityView(task.getViewNamed("public")); + + Assertions.assertEquals( + "" + + "" + + "
idtitlenotes
1<Task & "One">'note'
", + new ApiResponseAsHtml(response).getHtml()); + } + + @Test + void jsonLinesUsesOneUnwrappedJsonObjectPerEntity() { + Thingifier thingifier = taskThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance first = createTask(thingifier, "One", "Simple", "secret"); + EntityInstance second = createTask(thingifier, "Two", "More", "secret"); + ArrayList instances = new ArrayList<>(); + instances.add(first); + instances.add(second); + + ApiResponse response = + ApiResponse.success() + .returnInstanceCollection(instances) + .resultContainsType(task) + .usingEntityView(task.getViewNamed("public")); + + Assertions.assertEquals( + "{\"id\":1,\"title\":\"One\",\"notes\":\"Simple\"," + + "\"metadata\":{\"tag\":\"ignored\"}}\n" + + "{\"id\":2,\"title\":\"Two\",\"notes\":\"More\"," + + "\"metadata\":{\"tag\":\"ignored\"}}", + new ApiResponseAsJsonLines(response, jsonThing).getJsonLines()); + } + + @Test + void jsonSequenceFramesEveryJsonObject() { + Thingifier thingifier = taskThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance instance = createTask(thingifier, "One", "Simple", "secret"); + + ApiResponse response = + ApiResponse.success() + .returnSingleInstance(instance) + .usingEntityView(task.getViewNamed("public")); + + Assertions.assertEquals( + "\u001E{\"id\":1,\"title\":\"One\",\"notes\":\"Simple\"," + + "\"metadata\":{\"tag\":\"ignored\"}}\n", + new ApiResponseAsJsonLines(response, jsonThing).getJsonSequence()); + } + + @Test + void emptyDelimitedCollectionStillIncludesHeaderWhenTypeIsKnown() { + Thingifier thingifier = taskThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + + ApiResponse response = + ApiResponse.success() + .returnInstanceCollection(new ArrayList<>()) + .resultContainsType(task) + .usingEntityView(task.getViewNamed("public")); + + Assertions.assertEquals( + "id,title,notes", new ApiResponseAsDelimitedText(response, ',').getText()); + } + + @Test + void errorResponsesRenderInAdditionalFormats() { + ApiResponse response = ApiResponse.error(400, ""); + + Assertions.assertEquals("", new ApiResponseAsPlainText(response).getText()); + Assertions.assertEquals( + "
  • <bad>
", new ApiResponseAsHtml(response).getHtml()); + Assertions.assertEquals( + "{\"errorMessage\":\"\"}", + new ApiResponseAsJsonLines(response, jsonThing).getJsonLines()); + Assertions.assertEquals( + "errorMessage\n", new ApiResponseAsDelimitedText(response, ',').getText()); + } + + private Thingifier taskThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("id", AUTO_INCREMENT)); + task.addField(Field.is("title", STRING)); + task.addField(Field.is("notes", STRING)); + task.addField(Field.is("hidden", STRING)); + task.addField(Field.is("metadata", OBJECT).withField(Field.is("tag", STRING))); + task.defineView("public").hideResponseFields("hidden"); + return thingifier; + } + + private EntityInstance createTask( + final Thingifier thingifier, + final String title, + final String notes, + final String hidden) { + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstanceDraft draft = + EntityInstanceDraft.forEntity(task) + .withField("title", title) + .withField("notes", notes) + .withField("hidden", hidden) + .withField("metadata.tag", "ignored"); + return thingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME).entities().create(draft); + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java index 69afdf07..5ffe2fff 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java @@ -47,6 +47,13 @@ void apiDocumentationShowsSwaggerUiLinkByDefault() { Assertions.assertFalse(docs.contains("Add ?download")); Assertions.assertFalse(docs.contains("class='mermaid'")); Assertions.assertFalse(docs.contains("mermaid.esm.min.mjs")); + Assertions.assertTrue(docs.contains("Accept: text/csv")); + Assertions.assertTrue(docs.contains("Accept: text/plain")); + Assertions.assertTrue(docs.contains("Accept: text/html")); + Assertions.assertTrue(docs.contains("Accept: application/x-ndjson")); + Assertions.assertTrue(docs.contains("Accept: application/jsonl")); + Assertions.assertTrue(docs.contains("Accept: application/json-seq")); + Assertions.assertTrue(docs.contains("Accept: text/tab-separated-values")); } @Test diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerEntityDescriptionTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerEntityDescriptionTest.java index cbfc4765..55042c2f 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerEntityDescriptionTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerizerEntityDescriptionTest.java @@ -3,7 +3,9 @@ import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.parameters.Parameter; +import java.util.List; import java.util.Set; import java.util.stream.Collectors; import org.junit.jupiter.api.Assertions; @@ -88,6 +90,32 @@ void relationshipRoutesExposeEditablePathParametersAndPostBodies() { .allMatch(parameter -> Boolean.TRUE.equals(parameter.getRequired()))); } + @Test + void responseContentAdvertisesAdditionalRepresentations() { + final OpenAPI openApi = new Swaggerizer(apiDefn(relationshipModel())).swagger(); + + final Content content = + openApi.getPaths().get("/projects").getGet().getResponses().get("200").getContent(); + + Assertions.assertEquals( + "#/components/schemas/projects", + content.get("application/json").getSchema().get$ref()); + Assertions.assertEquals( + "#/components/schemas/projects", + content.get("application/xml").getSchema().get$ref()); + for (String mediaType : + List.of( + "text/csv", + "text/plain", + "text/html", + "application/x-ndjson", + "application/jsonl", + "application/json-seq", + "text/tab-separated-values")) { + Assertions.assertEquals("string", content.get(mediaType).getSchema().getType()); + } + } + @Test void filterableCollectionOperationsExposeSortByParameter() { final OpenAPI openApi = new Swaggerizer(apiDefn(relationshipModel())).swagger();