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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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<ACCEPT_TYPE, List<String>> acceptedTypes;

public boolean willAcceptAnything() {
return willAccept(ACCEPT_TYPE.ANYTHING);
Expand All @@ -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);
}
Expand All @@ -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;
}
Expand All @@ -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<String> mediaTypes;

ACCEPT_TYPE(final String... mediaTypes) {
this.mediaTypes = List.of(mediaTypes);
}

public String mediaType() {
if (mediaTypes.isEmpty()) {
return "";
}
return mediaTypes.get(0);
}

public List<String> mediaTypes() {
return mediaTypes;
}

public boolean hasConcreteResponseMediaType() {
return this != ANYTHING && this != NO_MATCHING_TYPE;
}

public boolean usesComponentSchemaInDocumentation() {
return this == JSON || this == XML;
}

public static List<ACCEPT_TYPE> 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
Expand All @@ -87,13 +159,6 @@ public AcceptHeaderParser(final String acceptHeader) {
this.acceptHeader = acceptHeader.trim().toLowerCase();
}

acceptedTypes = new HashMap<ACCEPT_TYPE, List<String>>();
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<>();
Expand Down Expand Up @@ -126,18 +191,36 @@ public boolean hasAPreferenceFor(final ACCEPT_TYPE type) {
return false;
}

public List<ACCEPT_TYPE> getSupportedTypesInPreferenceOrder() {
List<ACCEPT_TYPE> supportedTypes = new ArrayList<>();
for (String acceptedType : acceptMediaTypeDefinitionsList) {
ACCEPT_TYPE matchingType = getMatchingType(acceptedType);
if (matchingType != ACCEPT_TYPE.NO_MATCHING_TYPE) {
supportedTypes.add(matchingType);
}
Comment on lines +194 to +200

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude zero-quality media types from response selection

When a client sends Accept: text/csv;q=0, application/json;q=1, the parser strips the parameters and still returns CSV first in this list, so HttpApiResponse responds with text/csv even though the client explicitly rejected it. Parse quality values, discard entries with q=0, and order the remaining representations by quality before selection.

Useful? React with 👍 / 👎.

}
return supportedTypes;
}

private ACCEPT_TYPE getMatchingType(final String matchMe) {
for (Map.Entry<ACCEPT_TYPE, List<String>> type : acceptedTypes.entrySet()) {
List<String> 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);
}
Expand All @@ -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
Expand All @@ -163,12 +274,11 @@ public boolean willAccept(final ACCEPT_TYPE type) {
}

public boolean hasAskedFor(final ACCEPT_TYPE type) {
List<String> 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;
}
}
Expand Down
Loading
Loading