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

Make it possible to add custom API documentation based on the deployment location #6355

Open
wants to merge 10 commits into
base: dev-2.x
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Use:
# <TypeName>[.<FieldName>].(description|deprecated)[.append]
#
# Examples
# // Replace the existing type description
# Quay.description=The place for boarding/alighting a vehicle
#
# // Append to the existing type description
# Quay.description.append=Append
#
# // Replace the existing field description
# Quay.name.description=The public name
#
# // Append to the existing field description
# Quay.name.description.append=(Source NSR)
#
# // Insert deprecated reason. Due to a bug in the Java GraphQL lib, an existing deprecated
# // reason cannot be updated. Deleting the reason from the schema, and adding it back using
# // the "default" TransmodelApiDocumentationProfile is a workaround.
# Quay.name.deprecated=This field is deprecated ...


TariffZone.description=A **zone** used to define a zonal fare structure in a zone-counting or \
zone-matrix system. This includes TariffZone, as well as the specialised FareZone elements. \
TariffZones are deprecated, please use FareZones. \
\
**TariffZone data will not be maintained from 1. MAY 2025 (Entur).**
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.opentripplanner.apis.support.graphql.injectdoc;

import org.opentripplanner.framework.doc.DocumentedEnum;

public enum ApiDocumentationProfile implements DocumentedEnum<ApiDocumentationProfile> {
DEFAULT,
ENTUR;

private static final String TYPE_DOC =
"""
List of available custom documentation profiles. A profile is used to inject custom
documentation like type and field description or a deprecated reason.

Currently, ONLY the Transmodel API supports this feature.
""";

@Override
public String typeDescription() {
return TYPE_DOC;
}

@Override
public String enumValueDescription() {
return switch (this) {
case DEFAULT -> "Default documentation is used.";
case ENTUR -> "Entur specific documentation. This deprecate features not supported at Entur," +
" Norway.";
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package org.opentripplanner.apis.support.graphql.injectdoc;

import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import javax.annotation.Nullable;
import org.opentripplanner.framework.application.OtpAppException;
import org.opentripplanner.utils.text.TextVariablesSubstitution;

/**
* Load custom documentation from a properties file and make it available to any
* consumer using the {@code type-name[.field-name]} as key for lookups.
*/
public class CustomDocumentation {

private static final String APPEND_SUFFIX = ".append";
private static final String DESCRIPTION_SUFFIX = ".description";
private static final String DEPRECATED_SUFFIX = ".deprecated";

/** Put custom documentaion in the following sandbox package */
private static final String DOC_PATH = "org/opentripplanner/ext/apis/transmodel/";
private static final String FILE_NAME = "custom-documentation";
private static final String FILE_EXTENSION = ".properties";

private static final CustomDocumentation EMPTY = new CustomDocumentation(Map.of());

private final Map<String, String> textMap;

/**
* Package local to be unit-testable
*/
CustomDocumentation(Map<String, String> textMap) {
this.textMap = textMap;
}

public static CustomDocumentation of(ApiDocumentationProfile profile) {
if (profile == ApiDocumentationProfile.DEFAULT) {
return EMPTY;
}
var map = loadCustomDocumentationFromPropertiesFile(profile);
return map.isEmpty() ? EMPTY : new CustomDocumentation(map);
}

public boolean isEmpty() {
return textMap.isEmpty();
}

/**
* Get documentation for a type. The given {@code typeName} is used as the key. The
* documentation text is resolved by:
* <ol>
* <li>
* first looking up the given {@code key} + {@code ".description"}. If a value is found, then
* the value is returned.
* <li>
* then {@code key} + {@code ".description.append"} is used. If a value is found the
* {@code originalDoc} + {@code value} is returned.
* </li>
* </ol>
* @param typeName Use {@code TYPE_NAME} or {@code TYPE_NAME.FIELD_NAME} as key.
*/
public Optional<String> typeDescription(String typeName, @Nullable String originalDoc) {
return text(typeName, DESCRIPTION_SUFFIX, originalDoc);
}

/**
* Same as {@link #typeDescription(String, String)} except the given {@code typeName} and
* {@code fieldName} is used as the key.
* <pre>
* key := typeName + "." fieldNAme
* </pre>
*/
public Optional<String> fieldDescription(
String typeName,
String fieldName,
@Nullable String originalDoc
) {
return text(key(typeName, fieldName), DESCRIPTION_SUFFIX, originalDoc);
}

/**
* Get <em>deprecated reason</em> for a field (types cannot be deprecated). The key
* ({@code key = typeName + '.' + fieldName} is used to retrieve the reason from the properties
* file. The deprecated documentation text is resolved by:
* <ol>
* <li>
* first looking up the given {@code key} + {@code ".deprecated"}. If a value is found, then
* the value is returned.
* <li>
* then {@code key} + {@code ".deprecated.append"} is used. If a value is found the
* {@code originalDoc} + {@code text} is returned.
* </li>
* </ol>
* Any {@code null} values are excluded from the result and if both the input {@code originalDoc}
* and the resolved value is {@code null}, then {@code empty} is returned.
*/
public Optional<String> fieldDeprecatedReason(
String typeName,
String fieldName,
@Nullable String originalDoc
) {
return text(key(typeName, fieldName), DEPRECATED_SUFFIX, originalDoc);
}

/* private methods */

/**
* Create a key from the given {@code typeName} and {@code fieldName}
*/
private static String key(String typeName, String fieldName) {
return typeName + "." + fieldName;
}

private Optional<String> text(String key, String suffix, @Nullable String originalText) {
final String k = key + suffix;
return text(k).or(() -> appendText(k, originalText));
}

private Optional<String> text(String key) {
return Optional.ofNullable(textMap.get(key));
}

private Optional<String> appendText(String key, @Nullable String originalText) {
String value = textMap.get(key + APPEND_SUFFIX);
if (value == null) {
return Optional.empty();
}
return originalText == null ? Optional.of(value) : Optional.of(originalText + "\n\n" + value);
}

/* private methods */

private static Map<String, String> loadCustomDocumentationFromPropertiesFile(
ApiDocumentationProfile profile
) {
try {
final String resource = resourceName(profile);
var input = ClassLoader.getSystemResourceAsStream(resource);
if (input == null) {
throw new OtpAppException("Resource not found: %s", resource);
}
var props = new Properties();
props.load(new InputStreamReader(input, StandardCharsets.UTF_8));
Map<String, String> map = new HashMap<>();

for (String key : props.stringPropertyNames()) {
String value = props.getProperty(key);
if (value == null) {
value = "";
}
map.put(key, value);
}
return TextVariablesSubstitution.insertVariables(
map,
varName -> errorHandlerVariableSubstitution(varName, resource)
);
} catch (IOException e) {
throw new RuntimeException(e);
}
}

private static void errorHandlerVariableSubstitution(String name, String source) {
throw new OtpAppException("Variable substitution failed for '${%s}' in %s.", name, source);
}

private static String resourceName(ApiDocumentationProfile profile) {
return DOC_PATH + FILE_NAME + "-" + profile.name().toLowerCase() + FILE_EXTENSION;
}
}
Loading
Loading