Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Refactor] XContentType to parse Accept or Content-Type headers #3077

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 @@ -2073,7 +2073,7 @@ protected final <Resp> Resp parseEntity(final HttpEntity entity, final CheckedFu
if (entity.getContentType() == null) {
throw new IllegalStateException("OpenSearch didn't return the [Content-Type] header, unable to parse response body");
}
XContentType xContentType = XContentType.fromMediaTypeOrFormat(entity.getContentType().getValue());
XContentType xContentType = XContentType.fromMediaType(entity.getContentType().getValue());
if (xContentType == null) {
throw new IllegalStateException("Unsupported Content-Type: " + entity.getContentType().getValue());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -806,7 +806,7 @@ public void testUpdate() throws IOException {

UpdateRequest parsedUpdateRequest = new UpdateRequest();

XContentType entityContentType = XContentType.fromMediaTypeOrFormat(entity.getContentType().getValue());
XContentType entityContentType = XContentType.fromMediaType(entity.getContentType().getValue());
try (XContentParser parser = createParser(entityContentType.xContent(), entity.getContent())) {
parsedUpdateRequest.fromXContent(parser);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

/*
* Modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

package org.opensearch.common.xcontent;

/**
* Abstracts a <a href="http://en.wikipedia.org/wiki/Internet_media_type">Media Type</a> and a format parameter.
* Media types are used as values on Content-Type and Accept headers
* format is an URL parameter, specifies response media type.
*/
public interface MediaType {
/**
* Returns a type part of a MediaType
* i.e. application for application/json
*/
String type();

/**
* Returns a subtype part of a MediaType.
* i.e. json for application/json
*/
String subtype();

/**
* Returns a corresponding format for a MediaType. i.e. json for application/json media type
* Can differ from the MediaType's subtype i.e plain/text has a subtype of text but format is txt
*/
String format();

/**
* returns a string representation of a media type.
*/
default String typeWithSubtype() {
return type() + "/" + subtype();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* The OpenSearch Contributors require contributions made to
* this file be licensed under the Apache-2.0 license or a
* compatible open source license.
*/

/*
* Licensed to Elasticsearch under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

/*
* Modifications Copyright OpenSearch Contributors. See
* GitHub history for details.
*/

package org.opensearch.common.xcontent;

import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

public class MediaTypeParser<T extends MediaType> {
private final Map<String, T> formatToMediaType;
private final Map<String, T> typeWithSubtypeToMediaType;

public MediaTypeParser(T[] acceptedMediaTypes) {
this(acceptedMediaTypes, Map.of());
}

public MediaTypeParser(T[] acceptedMediaTypes, Map<String, T> additionalMediaTypes) {
final int size = acceptedMediaTypes.length + additionalMediaTypes.size();
Map<String, T> formatMap = new HashMap<>(size);
Map<String, T> typeMap = new HashMap<>(size);
for (T mediaType : acceptedMediaTypes) {
typeMap.put(mediaType.typeWithSubtype(), mediaType);
formatMap.put(mediaType.format(), mediaType);
}
for (Map.Entry<String, T> entry : additionalMediaTypes.entrySet()) {
String typeWithSubtype = entry.getKey();
T mediaType = entry.getValue();

typeMap.put(typeWithSubtype.toLowerCase(Locale.ROOT), mediaType);
formatMap.put(mediaType.format(), mediaType);
}

this.formatToMediaType = Map.copyOf(formatMap);
this.typeWithSubtypeToMediaType = Map.copyOf(typeMap);
}

public T fromMediaType(String mediaType) {
ParsedMediaType parsedMediaType = parseMediaType(mediaType);
return parsedMediaType != null ? parsedMediaType.getMediaType() : null;
}

public T fromFormat(String format) {
if (format == null) {
return null;
}
return formatToMediaType.get(format.toLowerCase(Locale.ROOT));
}

/**
* parsing media type that follows https://tools.ietf.org/html/rfc7231#section-3.1.1.1
* @param headerValue a header value from Accept or Content-Type
* @return a parsed media-type
*/
public ParsedMediaType parseMediaType(String headerValue) {
if (headerValue != null) {
String[] split = headerValue.toLowerCase(Locale.ROOT).split(";");

String[] typeSubtype = split[0].trim().split("/");
if (typeSubtype.length == 2) {
String type = typeSubtype[0];
String subtype = typeSubtype[1];
T xContentType = typeWithSubtypeToMediaType.get(type + "/" + subtype);
if (xContentType != null) {
Map<String, String> parameters = new HashMap<>();
for (int i = 1; i < split.length; i++) {
// spaces are allowed between parameters, but not between '=' sign
String[] keyValueParam = split[i].trim().split("=");
if (keyValueParam.length != 2 || hasSpaces(keyValueParam[0]) || hasSpaces(keyValueParam[1])) {
return null;
}
parameters.put(keyValueParam[0], keyValueParam[1]);
}
return new ParsedMediaType(xContentType, parameters);
}
}

}
return null;
}

private boolean hasSpaces(String s) {
return s.trim().equals(s) == false;
}

/**
* A media type object that contains all the information provided on a Content-Type or Accept header
*/
public class ParsedMediaType {
private final Map<String, String> parameters;
private final T mediaType;

public ParsedMediaType(T mediaType, Map<String, String> parameters) {
this.parameters = parameters;
this.mediaType = mediaType;
}

public T getMediaType() {
return mediaType;
}

public Map<String, String> getParameters() {
return parameters;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@
import org.opensearch.common.xcontent.yaml.YamlXContent;

import java.util.Locale;
import java.util.Objects;
import java.util.Map;

/**
* The content type of {@link org.opensearch.common.xcontent.XContent}.
*/
public enum XContentType {
public enum XContentType implements MediaType {

/**
* A JSON based content type.
Expand All @@ -60,7 +60,7 @@ public String mediaType() {
}

@Override
public String shortName() {
public String subtype() {
return "json";
}

Expand All @@ -79,7 +79,7 @@ public String mediaTypeWithoutParameters() {
}

@Override
public String shortName() {
public String subtype() {
return "smile";
}

Expand All @@ -98,7 +98,7 @@ public String mediaTypeWithoutParameters() {
}

@Override
public String shortName() {
public String subtype() {
return "yaml";
}

Expand All @@ -117,7 +117,7 @@ public String mediaTypeWithoutParameters() {
}

@Override
public String shortName() {
public String subtype() {
return "cbor";
}

Expand All @@ -127,73 +127,55 @@ public XContent xContent() {
}
};

/** a parser of media types */
private static final MediaTypeParser<XContentType> MEDIA_TYPE_PARSER = new MediaTypeParser<>(
XContentType.values(),
Map.of("application/*", JSON, "application/x-ndjson", JSON)
);

/** gets the {@link MediaTypeParser} singleton for use outside class */
@SuppressWarnings("rawtypes")
public static MediaTypeParser getMediaTypeParser() {
return MEDIA_TYPE_PARSER;
}

/**
* Accepts either a format string, which is equivalent to {@link XContentType#shortName()} or a media type that optionally has
* parameters and attempts to match the value to an {@link XContentType}. The comparisons are done in lower case format and this method
* also supports a wildcard accept for {@code application/*}. This method can be used to parse the {@code Accept} HTTP header or a
* format query string parameter. This method will return {@code null} if no match is found
* Accepts a format string, which is most of the time is equivalent to {@link XContentType#subtype()}
* and attempts to match the value to an {@link XContentType}.
* The comparisons are done in lower case format.
* This method will return {@code null} if no match is found
*/
public static XContentType fromMediaTypeOrFormat(String mediaType) {
if (mediaType == null) {
return null;
}

mediaType = removeVersionInMediaType(mediaType);
for (XContentType type : values()) {
if (isSameMediaTypeOrFormatAs(mediaType, type)) {
return type;
}
}
final String lowercaseMediaType = mediaType.toLowerCase(Locale.ROOT);
if (lowercaseMediaType.startsWith("application/*")) {
return JSON;
}
public static XContentType fromFormat(String mediaType) {
return MEDIA_TYPE_PARSER.fromFormat(mediaType);
}

return null;
/**
* Attempts to match the given media type with the known {@link XContentType} values. This match is done in a case-insensitive manner.
* The provided media type can optionally has parameters.
* This method is suitable for parsing of the {@code Content-Type} and {@code Accept} HTTP headers.
* This method will return {@code null} if no match is found
*/
public static XContentType fromMediaType(String mediaTypeHeaderValue) {
mediaTypeHeaderValue = removeVersionInMediaType(mediaTypeHeaderValue);
return MEDIA_TYPE_PARSER.fromMediaType(mediaTypeHeaderValue);
}

/**
* Clients compatible with ES 7.x might start sending media types with versioned media type
* in a form of application/vnd.opensearch+json;compatible-with=7.
* in a form of application/vnd.elasticsearch+json;compatible-with=7.
* This has to be removed in order to be used in 7.x server.
* The same client connecting using that media type will be able to communicate with ES 8 thanks to compatible API.
* @param mediaType - a media type used on Content-Type header, might contain versioned media type.
*
* @return a media type string without
*/
private static String removeVersionInMediaType(String mediaType) {
if (mediaType.contains("vnd.opensearch")) {
if (mediaType != null && (mediaType = mediaType.toLowerCase(Locale.ROOT)).contains("vnd.opensearch")) {
return mediaType.replaceAll("vnd.opensearch\\+", "").replaceAll("\\s*;\\s*compatible-with=\\d+", "");
}
return mediaType;
}

/**
* Attempts to match the given media type with the known {@link XContentType} values. This match is done in a case-insensitive manner.
* The provided media type should not include any parameters. This method is suitable for parsing part of the {@code Content-Type}
* HTTP header. This method will return {@code null} if no match is found
*/
public static XContentType fromMediaType(String mediaType) {
final String lowercaseMediaType = Objects.requireNonNull(mediaType, "mediaType cannot be null").toLowerCase(Locale.ROOT);
for (XContentType type : values()) {
if (type.mediaTypeWithoutParameters().equals(lowercaseMediaType)) {
return type;
}
}
// we also support newline delimited JSON: http://specs.okfnlabs.org/ndjson/
if (lowercaseMediaType.toLowerCase(Locale.ROOT).equals("application/x-ndjson")) {
return XContentType.JSON;
}

return null;
}

private static boolean isSameMediaTypeOrFormatAs(String stringType, XContentType type) {
return type.mediaTypeWithoutParameters().equalsIgnoreCase(stringType)
|| stringType.toLowerCase(Locale.ROOT).startsWith(type.mediaTypeWithoutParameters().toLowerCase(Locale.ROOT) + ";")
|| type.shortName().equalsIgnoreCase(stringType);
}

private int index;

XContentType(int index) {
Expand All @@ -208,10 +190,17 @@ public String mediaType() {
return mediaTypeWithoutParameters();
}

public abstract String shortName();

public abstract XContent xContent();

public abstract String mediaTypeWithoutParameters();

@Override
public String type() {
return "application";
}

@Override
public String format() {
return subtype();
}
}
Loading