Skip to content
Open
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
25 changes: 25 additions & 0 deletions NOTICE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,31 @@ License: ISC https://github.com/d3/d3/blob/main/LICENSE
This product includes the highlight.js Javascript library created by Ivan Sagalaev
Copyright (c) 2006, Ivan Sagalaev, https://github.com/isagalaev/highlight.js

This product includes the Scalar API Reference JavaScript library.
Copyright (c) 2023-present Scalar, https://github.com/scalar/scalar

MIT License

Copyright (c) 2023-present Scalar

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

This product includes the Chosen Javascript library created by Patrick Filler
Copyright (c) 2011-2014 by Harvest, https://github.com/harvesthq/chosen

Expand Down
7 changes: 7 additions & 0 deletions changelog/unreleased/PR-5201-api-explorer.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
title: Add an API Explorer to the Solr Admin UI
type: added
authors:
- name: Eric Pugh
links:
- name: PR#5201
url: https://github.com/apache/solr/pull/5201
2 changes: 2 additions & 0 deletions dev-docs/apis.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ Many libraries implement the JAX-RS spec: Solr currently uses the implementation
These v2 API definitions consist of two parts: a JAX-RS annotated interface in the `api` module "defining" the API, and a class in `core` "implementing" the interface.
Separating the API "definition" and "implementation" in this way allows us to only define each API in a single place, and use code generators to produce other API-related bits such as SolrJ code and ref-guide documentation.

One of those other bits is an OpenAPI spec - see link:local-api-explorer.adoc[local-api-explorer.adoc] for how to browse it with the API Explorer against a running local Solr.

=== Writing JAX-RS APIs

Writing a new v2 API may appear daunting, but additions in reality are actually pretty simple:
Expand Down
29 changes: 29 additions & 0 deletions dev-docs/local-api-explorer.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
= Browsing the V2 API with the API Explorer

Solr's v2 (JAX-RS) API is described by an OpenAPI spec generated at build time
from the annotated interfaces in `solr/api/src/java/.../client/api/endpoint`.
The API Explorer is bundled in the Admin UI and renders the generated spec using Scalar.
It's loaded in an iframe from `solr/webapp/web/libs/scalar/index.html` rather than mounted
directly into the Angular app: Scalar manages its own URL hash for deep-linking to operations,
which conflicts with AngularJS's `ngRoute` (both rewrite `window.location.hash`), causing an
infinite digest/navigation loop. A dedicated document/window avoids the conflict entirely.

== Usage

Build and start a local Solr with `./gradlew dev` and `bin/solr start -c`.
Open the *API Explorer* item in the Admin UI's left nav to browse endpoints and send requests.
The page and its assets are served locally; API requests go directly to the same Solr origin.
Executing a request can change the running instance, just as calling its API directly can.

Rebuild the webapp after changing API annotations to refresh the explorer's spec.

== Maintaining Scalar

`solr/webapp/scalar` downloads the `@scalar/api-reference` npm package at build time and
copies its `dist/browser/standalone.js` into the webapp's war, so the bundle is never
checked into source control. Its version is recorded as `scalar-api-reference` in
`gradle/libs.versions.toml`; bump that version to upgrade, and review the new version's
license and bundled dependencies. The MIT license text is recorded in the root `NOTICE.txt`.
The initializer lives in `solr/webapp/web/libs/scalar/scalar-init.js` as a
separate script so it also works with a policy that disallows inline scripts.
Default CDN fonts, the request proxy, AI chat, hosted-client links, developer tools, and telemetry are disabled.
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ nodegradle-node = "7.1.0"
nodejs = "22.18.0"
onnx = "1.29.0"
openapi = "7.25.0"
scalar-api-reference = "1.46.4"
openjdk-jmh = "1.37"
opentelemetry = "1.65.0"
opentelemetry-prometheus = "1.65.0-alpha"
Expand Down
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ gradle.ext.withJsClient = disableJsClientValue == null || disableJsClientValue !
if (gradle.ext.withJsClient) {
include(":solr:webapp:js-client")
}
include(":solr:webapp:scalar")

// Configures development for joint Lucene/ Solr composite build.
apply from: file('gradle/lucene-dev/lucene-dev-repo-composite.gradle')
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License;
import io.swagger.v3.oas.annotations.servers.Server;

@OpenAPIDefinition(
info =
@Info(
title = "v2 API",
description = "OpenAPI spec for Solr's v2 API endpoints",
license = @License(name = "ASL 2.0"),
version = SolrVersion.LATEST_STRING))
version = SolrVersion.LATEST_STRING),
// A relative URL keeps API-explorer tooling (e.g. Swagger UI) same-origin with
// whichever node ends up serving it, so "try it out" calls need no CORS setup.
servers = @Server(url = "/api", description = "This Solr node"))
public class ApiMetadata {}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ public void doGet(HttpServletRequest request, HttpServletResponse response) thro
HttpHeaders.CONTENT_SECURITY_POLICY,
"default-src 'none'; base-uri 'none'; connect-src "
+ connectSrc
+ "; form-action 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self';");
// frame-src: the Admin UI's API Explorer loads a same-origin page in an
// iframe (Scalar manages its own URL hash for deep-linking, which conflicts
// with AngularJS's ngRoute if mounted directly into the app instead).
+ "; form-action 'self'; font-src 'self'; frame-ancestors 'none'; frame-src 'self'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self';");

// We have to close this to flush OutputStreamWriter buffer
try (Writer out =
Expand Down
16 changes: 16 additions & 0 deletions solr/server/etc/jetty.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,22 @@
</New>
</Arg>
</Call>
<!-- The Admin UI's API Explorer loads this one page in an iframe (Scalar manages its
own URL hash for deep-linking, which conflicts with ngRoute if mounted directly);
this narrower rule runs after the blanket rule above and overrides frame-ancestors
for just this path, so same-origin framing isn't blocked. -->
<Call name="addRule">
<Arg>
<New class="org.eclipse.jetty.rewrite.handler.HeaderPatternRule">
<Set name="pattern">/solr/libs/scalar/index.html</Set>
<Set name="headerName">Content-Security-Policy</Set>
<Set name="headerValue">default-src 'none'; base-uri 'none'; connect-src 'self'; form-action 'self'; font-src 'self'; frame-ancestors 'self'; img-src 'self' data:; media-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; worker-src 'self';</Set>
</New>
</Arg>
</Call>
<!-- Note: /solr/index.html (and /solr/) is served by LoadAdminUiServlet (see web.xml),
which sets its own Content-Security-Policy that overrides anything set here - its
frame-src 'self' addition (needed by the API Explorer iframe above) lives there. -->
<Call name="addRule">
<Arg>
<New class="org.eclipse.jetty.rewrite.handler.HeaderPatternRule">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ The path to the Solr Admin UI given above is `\http://hostname:port/solr`, which
A convenience redirect is also supported, so simply accessing the Admin UI at `\http://hostname:port/` will also redirect to `\http://hostname:port/solr/#/`.
====

=== API Explorer

The *API Explorer* item in the Admin UI's left-hand navigation opens interactive documentation for Solr's v2 API.
It renders the OpenAPI specification bundled with Solr and lets you browse endpoints and send requests to the running instance.
Requests use the instance's authentication and authorization rules and can modify its data or configuration.

=== Login Screen

If authentication has been enabled, Solr will present a login screen to unauthenticated users before allowing them further access to the Admin UI.
Expand Down
31 changes: 31 additions & 0 deletions solr/webapp/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ configurations {
solrCore
generatedJSClientBundle
uiBundle
openapiSpec
scalarBundle
}

dependencies {
Expand All @@ -53,6 +55,10 @@ dependencies {
generatedJSClientBundle project(path: ":solr:webapp:js-client", configuration: "jsClientBundle")
}
uiBundle files(layout.buildDirectory.dir("generated/ui-resources")) { builtBy tasks.named('prepareUiResources') }
// Supplies the generated specification to the API Explorer.
openapiSpec project(path: ":solr:api", configuration: "openapiSpec")
// Supplies the Scalar browser bundle that renders the API Explorer.
scalarBundle project(path: ":solr:webapp:scalar", configuration: "scalarBundle")

// Browser-based tests of the AngularJS Admin UI (see src/test)
testImplementation project(':solr:core')
Expand Down Expand Up @@ -108,6 +114,20 @@ tasks.withType(Test).configureEach {
logger.warn('NOTE: :solr:webapp tests are disabled because -PdisableJsClient=true ' +
'excludes the js-client bundle the Admin UI tests need.')
}

// The API Explorer's iframe page loads the Scalar bundle and fetches the OpenAPI spec;
// both are build-time-generated files the war ships, and like the js-client bundle above,
// neither exists in the source tree these tests serve.
if (seleniumEnabled) {
def scalarBundle = configurations.scalarBundle
def openapiSpec = configurations.openapiSpec
inputs.files(scalarBundle).withPropertyName('scalarBundle')
inputs.files(openapiSpec).withPropertyName('openapiSpec')
doFirst {
systemProperty 'tests.ui.scalar.bundle', new File(scalarBundle.singleFile, 'standalone.js')
systemProperty 'tests.ui.scalar.spec', openapiSpec.singleFile
}
}
}

war {
Expand All @@ -121,6 +141,17 @@ war {
from(configurations.uiBundle, {
into "ui"
})

// Supplies the generated specification to the API Explorer.
from(configurations.openapiSpec, {
into "libs/scalar"
rename { "spec.json" }
})

// Supplies the Scalar browser bundle that renders the API Explorer.
from(configurations.scalarBundle, {
into "libs/scalar"
})
}

// Expose 'war' archive as an artifact so that it can be packaged in the distribution.
Expand Down
2 changes: 1 addition & 1 deletion solr/webapp/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,4 @@ org.slf4j:jcl-over-slf4j:2.0.20=jarValidation,serverLib,solrCore,testRuntimeClas
org.slf4j:jul-to-slf4j:2.0.20=serverLib
org.slf4j:slf4j-api:2.0.20=jarValidation,serverLib,solrCore,testCompileClasspath,testRuntimeClasspath
org.xerial.snappy:snappy-java:1.1.10.8=jarValidation,solrCore,testRuntimeClasspath
empty=compileClasspath,generatedJSClientBundle,generatedUIBundle,missingdoclet,providedCompile,providedRuntime,runtimeClasspath,uiBundle,war
empty=compileClasspath,generatedJSClientBundle,generatedUIBundle,missingdoclet,openapiSpec,providedCompile,providedRuntime,runtimeClasspath,uiBundle,war
59 changes: 59 additions & 0 deletions solr/webapp/scalar/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF 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.
*/

import com.github.gradle.node.npm.task.NpmTask

// Downloads the pre-built @scalar/api-reference browser bundle via npm, for :solr:webapp to
// include in the war. Keeps the large generated bundle out of source control, unlike a
// hand-vendored copy.

plugins {
id("base")
alias(libs.plugins.nodegradle.node)
}

description = "Downloads the Scalar API Reference browser bundle for the Admin UI's API Explorer"

val scalarBundleDir = layout.buildDirectory.dir("scalarBundle").get().asFile

val scalarBundle = configurations.create("scalarBundle") {
isCanBeConsumed = true
isCanBeResolved = false
}

val downloadScalarApiReference = tasks.register<NpmTask>("downloadScalarApiReference") {
args.set(listOf("install", "@scalar/api-reference@${libs.versions.scalar.api.reference.get()}"))

inputs.property("scalar-api-reference version", libs.versions.scalar.api.reference.get())
outputs.dir(project.extra["nodeProjectDir"].toString() + "/node_modules/@scalar/api-reference")
}

val copyScalarBundle = tasks.register<Sync>("copyScalarBundle") {
dependsOn(downloadScalarApiReference)

from(project.extra["nodeProjectDir"].toString() + "/node_modules/@scalar/api-reference/dist/browser") {
include("standalone.js")
}

into(scalarBundleDir)
}

artifacts {
add("scalarBundle", scalarBundleDir) {
builtBy(copyScalarBundle)
}
}
66 changes: 64 additions & 2 deletions solr/webapp/src/test/org/apache/solr/webapp/AdminUiTestBase.java
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,59 @@ private static Path jsClientBundlePath() {
return Files.isReadable(bundle) ? bundle : null;
}

/**
* Serves the Scalar browser bundle the API Explorer page loads (in an iframe, isolated from the
* Angular app - see {@code solr/webapp/web/libs/scalar/index.html}): built by {@code
* :solr:webapp:scalar} and its location handed to the test JVM in {@code tests.ui.scalar.bundle};
* it only exists inside the built webapp, not in the source tree tests serve from.
*/
public static class ScalarBundleServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/javascript");
Files.copy(scalarBundlePath(), resp.getOutputStream());
}
}

/**
* Serves the generated OpenAPI spec the API Explorer page fetches: built by {@code :solr:api} and
* its location handed to the test JVM in {@code tests.ui.scalar.spec}.
*/
public static class ScalarSpecServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("application/json");
Files.copy(scalarSpecPath(), resp.getOutputStream());
}
}

/**
* The generated Scalar bundle: handed to us by the build in {@code tests.ui.scalar.bundle}, with
* the scalar build's output location as a fallback so tests can also run from an IDE (after a
* Gradle build has produced the bundle). Null when unavailable.
*/
private static Path scalarBundlePath() {
String path = EnvUtils.getProperty("tests.ui.scalar.bundle");
if (path == null && ExternalPaths.SOURCE_HOME == null) {
return null;
}
Path bundle =
path != null
? Path.of(path)
: ExternalPaths.SOURCE_HOME.resolve("webapp/scalar/build/scalarBundle/standalone.js");
return Files.isReadable(bundle) ? bundle : null;
}

/** The generated OpenAPI spec: handed to us by the build in {@code tests.ui.scalar.spec}. */
private static Path scalarSpecPath() {
String path = EnvUtils.getProperty("tests.ui.scalar.spec");
if (path == null) {
return null;
}
Path spec = Path.of(path);
return Files.isReadable(spec) ? spec : null;
}

/** Ignores threads spawned by Selenium and the JDK http client it uses. */
public static class WebDriverThreadsFilter implements ThreadFilter {
@Override
Expand Down Expand Up @@ -193,6 +246,11 @@ public static void startClusterAndBrowser() throws Exception {
"No generated js-client bundle available; the Gradle build wires it via"
+ " tests.ui.jsclient.bundle (is -PdisableJsClient=true set?)");
}
if (scalarBundlePath() == null) {
fail(
"No generated Scalar bundle available; the Gradle build wires it via"
+ " tests.ui.scalar.bundle");
}

// metrics are off by default in test clusters, but UI screens (e.g. Plugins) need them;
// restored after the class by SolrTestCase's SystemPropertiesRestoreRule
Expand Down Expand Up @@ -240,12 +298,16 @@ protected static void ensureCloudCluster() {
}
}

/** Configures a Jetty node to serve the Admin UI plus the js-client bundle. */
/** Configures a Jetty node to serve the Admin UI plus the js-client and Scalar bundles. */
protected static void configureJettyForUi(JettyConfig.Builder jetty) {
jetty
.enableAdminUi(true)
// exact-path mapping takes precedence over the static /libs/* servlet
.withServlet(new ServletHolder(new JsClientServlet()), "/libs/solr/index.js");
.withServlet(new ServletHolder(new JsClientServlet()), "/libs/solr/index.js")
.withServlet(new ServletHolder(new ScalarBundleServlet()), "/libs/scalar/standalone.js");
if (scalarSpecPath() != null) {
jetty.withServlet(new ServletHolder(new ScalarSpecServlet()), "/libs/scalar/spec.json");
}
}

@AfterClass
Expand Down
5 changes: 5 additions & 0 deletions solr/webapp/web/css/angular/common.css
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,11 @@ ul
width: 100%;
}

#api-explorer iframe
{
height: calc(100vh - 160px);
}

#content .block
{
margin-bottom: 10px;
Expand Down
2 changes: 2 additions & 0 deletions solr/webapp/web/css/angular/menu.css
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ limitations under the License.

#menu #threads.global p a { background-image: url( ../../img/ico/ui-accordion.png ); }

#menu #api-explorer.global p a { background-image: url( ../../img/ico/openapi.png ); }

#menu #new-ui.global p a { background-image: url( ../../img/ico/plus-button.png ); }

#menu #collections.global p a { background-image: url( ../../img/ico/documents-stack.png ); }
Expand Down
Binary file added solr/webapp/web/img/ico/openapi.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading