Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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 @@ -23,6 +23,7 @@

import java.time.Clock;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
Expand Down Expand Up @@ -133,7 +134,7 @@ public DocService() {
this(/* exampleHeaders */ ImmutableMap.of(), /* exampleRequests */ ImmutableMap.of(),
/* examplePaths */ ImmutableMap.of(), /* exampleQueries */ ImmutableMap.of(),
/* injectedScriptSuppliers */ ImmutableList.of(), DocServiceBuilder.ALL_SERVICES,
null);
null, new HashMap<>());
}

/**
Expand All @@ -144,19 +145,21 @@ public DocService() {
Map<String, ListMultimap<String, String>> examplePaths,
Map<String, ListMultimap<String, String>> exampleQueries,
List<BiFunction<ServiceRequestContext, HttpRequest, String>> injectedScriptSuppliers,
DocServiceFilter filter, @Nullable DescriptiveTypeInfoProvider descriptiveTypeInfoProvider) {
DocServiceFilter filter, @Nullable DescriptiveTypeInfoProvider descriptiveTypeInfoProvider,
Map<String, String> docServiceExtraInfo) {
this(new ExampleSupport(immutableCopyOf(exampleHeaders, "exampleHeaders"),
immutableCopyOf(exampleRequests, "exampleRequests"),
immutableCopyOf(examplePaths, "examplePaths"),
immutableCopyOf(exampleQueries, "exampleQueries")),
injectedScriptSuppliers, filter, descriptiveTypeInfoProvider);
injectedScriptSuppliers, filter, descriptiveTypeInfoProvider, docServiceExtraInfo);
}

private DocService(ExampleSupport exampleSupport,
List<BiFunction<ServiceRequestContext, HttpRequest, String>> injectedScriptSuppliers,
DocServiceFilter filter,
@Nullable DescriptiveTypeInfoProvider descriptiveTypeInfoProvider) {
this(new SpecificationLoader(exampleSupport, filter, descriptiveTypeInfoProvider),
@Nullable DescriptiveTypeInfoProvider descriptiveTypeInfoProvider,
Map<String, String> docServiceExtraInfo) {
this(new SpecificationLoader(exampleSupport, filter, descriptiveTypeInfoProvider, docServiceExtraInfo),
injectedScriptSuppliers);
}

Expand Down Expand Up @@ -255,14 +258,17 @@ static final class SpecificationLoader {
private final DescriptiveTypeInfoProvider descriptiveTypeInfoProvider;
private final Map<String, CompletableFuture<AggregatedHttpFile>> files = new ConcurrentHashMap<>();
private List<ServiceConfig> services = Collections.emptyList();
private final Map<String, String> docServiceExtraInfo;

SpecificationLoader(
ExampleSupport exampleSupport,
DocServiceFilter filter,
@Nullable DescriptiveTypeInfoProvider descriptiveTypeInfoProvider) {
@Nullable DescriptiveTypeInfoProvider descriptiveTypeInfoProvider,
Map<String, String> docServiceExtraInfo) {
this.exampleSupport = exampleSupport;
this.filter = filter;
this.descriptiveTypeInfoProvider = composeDescriptiveTypeInfoProvider(descriptiveTypeInfoProvider);
this.docServiceExtraInfo = requireNonNull(docServiceExtraInfo, "docServiceExtraInfo");
}

boolean contains(String path) {
Expand Down Expand Up @@ -324,6 +330,7 @@ private CompletableFuture<ServiceSpecification> generateServiceSpecification(Exe
ServiceSpecification spec = generate(services, docServiceRoute);
spec = docStringSupport.addDocStrings(spec);
spec = exampleSupport.addExamples(spec);
spec.setDocServiceExtraInfo(docServiceExtraInfo);
return spec;
}, executor);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ public final class DocServiceBuilder {

static final DocServiceFilter NO_SERVICE = (plugin, service, method) -> false;

private static final String WEB_APP_TITLE_KEY = "webAppTitle";

private static final int WEB_APP_TITLE_MAX_SIZE = 50;

private DocServiceFilter includeFilter = ALL_SERVICES;

private DocServiceFilter excludeFilter = NO_SERVICE;
Expand All @@ -61,6 +65,7 @@ public final class DocServiceBuilder {
private final Map<String, ListMultimap<String, String>> exampleQueries = new HashMap<>();
private final List<BiFunction<ServiceRequestContext, HttpRequest, String>> injectedScriptSuppliers =
new ArrayList<>();
private final Map<String, String> docServiceExtraInfo = new HashMap<>();

@Nullable
private DescriptiveTypeInfoProvider descriptiveTypeInfoProvider;
Expand Down Expand Up @@ -551,12 +556,27 @@ private static String[] guessAndSerializeExampleRequest(Object exampleRequest) {
}
}

/**
* Sets the title of the web application to be used in the documentation service.
* @param webAppTitle The title cannot be null, empty, or exceed a maximum length of 50 characters.
* @return The current {@link DocServiceBuilder} instance for method chaining.
*/
@UnstableApi
public DocServiceBuilder webAppTitle(String webAppTitle) {
requireNonNull(webAppTitle, WEB_APP_TITLE_KEY);
checkArgument(!webAppTitle.trim().isEmpty(), "%s is empty.", WEB_APP_TITLE_KEY);
checkArgument(webAppTitle.length() <= WEB_APP_TITLE_MAX_SIZE,
"%s length exceeds %s.", WEB_APP_TITLE_KEY, WEB_APP_TITLE_MAX_SIZE);
docServiceExtraInfo.putIfAbsent(WEB_APP_TITLE_KEY, webAppTitle);
return this;
}

/**
* Returns a newly-created {@link DocService} based on the properties of this builder.
*/
public DocService build() {
return new DocService(exampleHeaders, exampleRequests, examplePaths, exampleQueries,
injectedScriptSuppliers, unifyFilter(includeFilter, excludeFilter),
descriptiveTypeInfoProvider);
descriptiveTypeInfoProvider, docServiceExtraInfo);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/*
* Copyright 2025 LY Corporation
*
* LY Corporation 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:
*
* https://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.
*/
package com.linecorp.armeria.server.docs;

import static com.google.common.base.Preconditions.checkArgument;
import static java.util.Objects.requireNonNull;

import java.util.regex.Pattern;

import com.linecorp.armeria.common.annotation.UnstableApi;

/**
* Provides utilities for {@link DocServiceBuilder#injectedScripts(String...)}.
*/
@UnstableApi
public final class DocServiceInjectableScripts {

private static final String HEX_COLOR_PATTERN = "^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$";
private static final int MAX_COLOR_LENGTH = 7;
private static final String SAFE_DOM_HOOK = "data-js-target";
private static final String TITLE_BACKGROUND_KEY = "titleBackground";
private static final String GOTO_BACKGROUND_KEY = "gotoBackground";
private static final String FAVICON_KEY = "favicon";

/**
* Returns a js script to change the title background to the specified color in hex code format.
*
* @param color the color string to set
* @return the js script
*/
public static String titleBackground(String color) {
final String targetAttr = "main-app-bar";
validateHexColor(color, TITLE_BACKGROUND_KEY);

return buildStyleScript(checkHashtagInHexColorCode(color), targetAttr);
}

/**
* Returns a js script to change the background of the goto component to the specified color in hex code
* format.
*
* @param color the color string to set
* @return the js script
*/
public static String gotoBackground(String color) {
final String targetAttr = "goto-app-bar";
validateHexColor(color, GOTO_BACKGROUND_KEY);

return buildStyleScript(checkHashtagInHexColorCode(color), targetAttr);
}

/**
* Returns a js script to change the web favicon.
*
* @param uri the uri string to set
* @return the js script
*/
public static String favicon(String uri) {
validateFaviconUri(uri, FAVICON_KEY);

return buildFaviconScript(escapeJavaScriptUri(uri));
}

private DocServiceInjectableScripts() {}

/**
* Validates that the given color is a non-null, non-empty, character hex color string.
*
* @param color the color string to validate
* @param key the name used in error messages
*/
private static void validateHexColor(String color, String key) {
requireNonNull(color, key);
checkArgument(!color.trim().isEmpty(), "%s is empty.", key);
checkArgument(color.length() <= MAX_COLOR_LENGTH,
"%s length exceeds %s.", key, MAX_COLOR_LENGTH);
checkArgument(Pattern.matches(HEX_COLOR_PATTERN, color),
"%s not in hex format: %s.", key, color);
}

/**
* Check if the given color starts with a hashtag char.
*
* @param color the color string to validate
* @return hex color string with hashtag included
*/
private static String checkHashtagInHexColorCode(String color) {

if (color.startsWith("#")) {
return color;
}
return '#' + color;
}

/**
* Builds a JavaScript snippet that sets the background color of a DOM element.
*
* @param color the background color in hex format
* @param targetAttr the value of the target attribute to match
* @return a JavaScript string that applies the background color to the element
*/
private static String buildStyleScript(String color, String targetAttr) {
return "{\n" +
" const element = document.querySelector('[" + SAFE_DOM_HOOK + "=\"" + targetAttr + "\"]');\n" +
" if (element) {\n" +
" element.style.backgroundColor = '" + color + "';\n" +
" }\n}\n";
}

/**
* Validates the favicon uri.
*
* @param uri the uri string to validate
* @param key the name used in error messages
*/
private static void validateFaviconUri(String uri, String key) {
requireNonNull(uri, key);
checkArgument(!uri.trim().isEmpty(), "%s is empty.", key);
}

/**
* Escapes special characters not filtered by other methods.
*
* @param uri the input string to escape
* @return the escaped string
*/
private static String escapeJavaScriptUri(String uri) {
final StringBuilder escaped = new StringBuilder();

for (int i = 0; i < uri.length(); i++) {
final char c = uri.charAt(i);

switch (c) {
case '\\':
escaped.append("\\\\");
break;
case '\'':
escaped.append("\\'");
break;
case '&':
escaped.append("\\u0026");
break;
case '=':
escaped.append("\\u003D");
break;
case '/':
escaped.append("\\/");
break;
default:
if (c > 126) {
escaped.append(String.format("\\u%04X", (int) c));
} else {
escaped.append(c);
}
break;
}
}

return escaped.toString();
}

/**
* Builds a JavaScript snippet that sets the new favicon.
*
* @param uri the uri string to set
* @return a JavaScript string that applies the favicon change
*/
private static String buildFaviconScript(String uri) {
return "{\n" +
" let link = document.querySelector('link[rel~=\"icon\"]');\n" +
" if (link) {\n" +
" document.head.removeChild(link);\n" +
" }\n" +
" link = document.createElement('link');\n" +
" link.rel = 'icon';\n" +
" link.type = 'image/x-icon';\n" +
" link.href = '" + uri + "';\n" +
" document.head.appendChild(link);\n" +
"}\n";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ private static void generateDescriptiveTypeInfos(
private final Set<StructInfo> structs;
private final Set<ExceptionInfo> exceptions;
private final List<HttpHeaders> exampleHeaders;
private Map<String, String> docServiceExtraInfo = new HashMap<>();

@Nullable
private final Route docServiceRoute;

Expand Down Expand Up @@ -249,4 +251,20 @@ public List<HttpHeaders> exampleHeaders() {
public Route docServiceRoute() {
return docServiceRoute;
}

/**
* Returns the extra info in this specification.
*/
@JsonProperty
public Map<String, String> docServiceExtraInfo() {
return docServiceExtraInfo;
}

/**
* * Sets the additional information for the document service.
* @param docServiceExtraInfo a map containing the extra information for the document service.
*/
public void setDocServiceExtraInfo(Map<String, String> docServiceExtraInfo) {
this.docServiceExtraInfo = requireNonNull(docServiceExtraInfo,"docServiceExtraInfo");
}
}
Loading
Loading