Fix #18110: Allow serving UI under a subpath (#18111)

* Fix #18110: Allow serving UI under a subpath

* Update ui package to pick up BASE_PATH

* apply java check style

* update

* update ui part

* update UI  paths

* fix unit tests

* fix build

* fix tests

---------

Co-authored-by: Chira Madlani <chirag@getcollate.io>
Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com>
Co-authored-by: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com>
This commit is contained in:
Sriharsha Chintalapani 2025-05-14 00:41:50 -07:00 committed by GitHub
parent a999236026
commit 2f4355bd4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 171 additions and 56 deletions

View File

@ -14,9 +14,15 @@ clusterName: ${OPENMETADATA_CLUSTER_NAME:-openmetadata}
swagger:
resourcePackage: org.openmetadata.service.resources
assets:
resourcePath: /assets/
uriPath: ${BASE_PATH:-/}
basePath: ${BASE_PATH:-/}
server:
rootPath: '/api/*'
applicationContextPath: ${BASE_PATH:-/}
rootPath: ${BASE_PATH:-/}api/*
applicationConnectors:
- type: http
bindHost: ${SERVER_HOST:-0.0.0.0}

View File

@ -262,7 +262,7 @@ public class OpenMetadataApplication extends Application<OpenMetadataApplication
registerSamlServlets(catalogConfig, environment);
// Asset Servlet Registration
registerAssetServlet(catalogConfig.getWebConfiguration(), environment);
registerAssetServlet(catalogConfig, catalogConfig.getWebConfiguration(), environment);
// Handle Services Jobs
registerHealthCheckJobs(catalogConfig);
@ -358,10 +358,15 @@ public class OpenMetadataApplication extends Application<OpenMetadataApplication
EnumSet.allOf(DispatcherType.class), true, eventMonitorConfiguration.getPathPattern());
}
private void registerAssetServlet(OMWebConfiguration webConfiguration, Environment environment) {
private void registerAssetServlet(
OpenMetadataApplicationConfig config,
OMWebConfiguration webConfiguration,
Environment environment) {
// Handle Asset Using Servlet
OpenMetadataAssetServlet assetServlet =
new OpenMetadataAssetServlet("/assets", "/", "index.html", webConfiguration);
new OpenMetadataAssetServlet(
config.getBasePath(), "/assets", "/", "index.html", webConfiguration);
String pathPattern = "/" + '*';
environment.servlets().addServlet("static", assetServlet).addMapping(pathPattern);
}

View File

@ -44,6 +44,13 @@ import org.openmetadata.service.util.JsonUtils;
@Getter
@Setter
public class OpenMetadataApplicationConfig extends Configuration {
@Getter @JsonProperty private String basePath;
@Getter
@JsonProperty("assets")
private Map<String, String> assets;
@JsonProperty("database")
@NotNull
@Valid

View File

@ -19,6 +19,8 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.HashMap;
import java.util.Map;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
@ -54,6 +56,14 @@ public class ConfigResource {
this.openMetadataApplicationConfig = config;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
public Map<String, String> getConfig() {
Map<String, String> config = new HashMap<>();
config.put("basePath", openMetadataApplicationConfig.getBasePath());
return config;
}
@GET
@Path(("/auth"))
@Operation(

View File

@ -0,0 +1,46 @@
package org.openmetadata.service.resources.system;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.stream.Collectors;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.openmetadata.service.OpenMetadataApplicationConfig;
@Path("/")
public class IndexResource {
private String indexHtml;
public IndexResource() {
InputStream inputStream = getClass().getResourceAsStream("/assets/index.html");
indexHtml =
new BufferedReader(new InputStreamReader(inputStream))
.lines()
.collect(Collectors.joining("\n"));
}
public void initialize(OpenMetadataApplicationConfig config) {
this.indexHtml = this.indexHtml.replace("${basePath}", config.getBasePath());
}
public static String getIndexFile(String basePath) {
InputStream inputStream = IndexResource.class.getResourceAsStream("/assets/index.html");
String indexHtml =
new BufferedReader(new InputStreamReader(inputStream))
.lines()
.collect(Collectors.joining("\n"));
return indexHtml.replace("${basePath}", basePath);
}
@GET
@Produces(MediaType.TEXT_HTML)
public Response getIndex() {
return Response.ok(indexHtml).build();
}
}

View File

@ -35,7 +35,14 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import java.net.URL;
import java.security.interfaces.RSAPublicKey;
import java.util.*;
import java.util.Arrays;
import java.util.Calendar;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.stream.Collectors;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;

View File

@ -23,20 +23,39 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.jetbrains.annotations.Nullable;
import org.openmetadata.service.config.OMWebConfiguration;
import org.openmetadata.service.resources.system.IndexResource;
public class OpenMetadataAssetServlet extends AssetServlet {
private final OMWebConfiguration webConfiguration;
private final String basePath;
public OpenMetadataAssetServlet(
String resourcePath, String uriPath, @Nullable String indexFile, OMWebConfiguration webConf) {
String basePath,
String resourcePath,
String uriPath,
@Nullable String indexFile,
OMWebConfiguration webConf) {
super(resourcePath, uriPath, indexFile, "text/html", StandardCharsets.UTF_8);
this.webConfiguration = webConf;
this.basePath = basePath;
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
setSecurityHeader(webConfiguration, resp);
String requestUri = req.getRequestURI();
if (requestUri.endsWith("/")) {
IndexResource index = new IndexResource();
// Write the dynamic config.js content to the response
resp.setContentType("text/html");
resp.getWriter().write(IndexResource.getIndexFile(this.basePath));
return;
}
super.doGet(req, resp);
if (!resp.isCommitted() && (resp.getStatus() == 404)) {
resp.sendError(404);

View File

@ -15,88 +15,95 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta
name="msapplication-TileImage"
content="/favicons/ms-icon-144x144.png"
content="${basePath}favicons/ms-icon-144x144.png"
/>
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="OpenMetadata Application" />
<meta property="og:title" content="OpenMetadata" />
<meta property="og:description" content="OpenMetadata Application" />
<meta property="og:image" content="/favicons/android-icon-192x192.png" />
<meta
property="og:image"
content="${basePath}favicons/android-icon-192x192.png"
/>
<script>
window.BASE_PATH = '${basePath}';
</script>
<link rel="shortcut icon" href="/favicon.png" type="image/png" />
<link rel="shortcut icon" href="${basePath}favicon.png" type="image/png" />
<link
rel="apple-touch-icon"
sizes="57x57"
href="/favicons/apple-icon-57x57.png"
href="${basePath}favicons/apple-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="/favicons/apple-icon-60x60.png"
href="${basePath}favicons/apple-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="/favicons/apple-icon-72x72.png"
href="${basePath}favicons/apple-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/favicons/apple-icon-76x76.png"
href="${basePath}favicons/apple-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="/favicons/apple-icon-114x114.png"
href="${basePath}favicons/apple-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/favicons/apple-icon-120x120.png"
href="${basePath}favicons/apple-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/favicons/apple-icon-144x144.png"
href="${basePath}favicons/apple-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/favicons/apple-icon-152x152.png"
href="${basePath}favicons/apple-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/favicons/apple-icon-180x180.png"
href="${basePath}favicons/apple-icon-180x180.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/favicons/android-icon-192x192.png"
href="${basePath}favicons/android-icon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicons/favicon-32x32.png"
href="${basePath}favicons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/favicons/favicon-96x96.png"
href="${basePath}favicons/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicons/favicon-16x16.png"
href="${basePath}favicons/favicon-16x16.png"
/>
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="${basePath}manifest.json" />
<title>OpenMetadata</title>
</head>

View File

@ -0,0 +1,16 @@
/*
* Copyright 2024 Collate.
* Licensed 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.
*/
interface Window {
BASE_PATH?: string;
}

View File

@ -11,7 +11,6 @@
* limitations under the License.
*/
import { renderHook } from '@testing-library/react-hooks';
import process from 'process';
import { useLocation } from 'react-router-dom';
import useCustomLocation from './useCustomLocation';
@ -22,7 +21,7 @@ jest.mock('react-router-dom', () => ({
describe('useCustomLocation', () => {
it('should modify the pathname correctly', () => {
// Mock the environment variable
process.env.APP_SUB_PATH = '/app';
window.BASE_PATH = '/app/';
// Mock the useLocation hook
(useLocation as jest.Mock).mockReturnValue({
@ -65,7 +64,7 @@ describe('useCustomLocation', () => {
it('should return the original location object if APP_SUB_PATH is not set', () => {
// Mock the environment variable
delete process.env.APP_SUB_PATH;
delete window.BASE_PATH;
// Mock the useLocation hook
(useLocation as jest.Mock).mockReturnValue({
@ -91,7 +90,7 @@ describe('useCustomLocation', () => {
it('should return the original location object if APP_SUB_PATH is empty', () => {
// Mock the environment variable
process.env.APP_SUB_PATH = '';
window.BASE_PATH = '';
// Mock the useLocation hook
(useLocation as jest.Mock).mockReturnValue({
@ -117,7 +116,7 @@ describe('useCustomLocation', () => {
it('should return the original location object if APP_SUB_PATH is not a prefix', () => {
// Mock the environment variable
process.env.APP_SUB_PATH = '/test';
window.BASE_PATH = '/test';
// Mock the useLocation hook
(useLocation as jest.Mock).mockReturnValue({
@ -143,7 +142,7 @@ describe('useCustomLocation', () => {
it('should return the updated pathname on second render', () => {
// Mock the environment variable
process.env.APP_SUB_PATH = '/app';
window.BASE_PATH = '/app/';
// Mock the useLocation hook
(useLocation as jest.Mock).mockReturnValue({

View File

@ -10,15 +10,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import process from 'process';
import { useLocation } from 'react-router-dom';
import { getBasePath } from '../../utils/HistoryUtils';
const useCustomLocation = (): ReturnType<typeof useLocation> => {
const location = useLocation();
const modifiedPathname = location.pathname.replace(
process.env.APP_SUB_PATH ?? '',
''
);
const modifiedPathname = location.pathname.replace(getBasePath(), '');
return {
...location,

View File

@ -13,9 +13,10 @@
import axios from 'axios';
import Qs from 'qs';
import { getBasePath } from '../utils/HistoryUtils';
const axiosClient = axios.create({
baseURL: '/api/v1',
baseURL: `${getBasePath()}/api/v1`,
paramsSerializer: (params) => Qs.stringify(params, { arrayFormat: 'comma' }),
});

View File

@ -21,7 +21,6 @@ import { CookieStorage } from 'cookie-storage';
import jwtDecode, { JwtPayload } from 'jwt-decode';
import { first, get, isEmpty, isNil } from 'lodash';
import { WebStorageStateStore } from 'oidc-client';
import process from 'process';
import {
AuthenticationConfigurationWithScope,
OidcUser,
@ -35,6 +34,7 @@ import {
} from '../generated/configuration/authenticationConfiguration';
import { AuthProvider } from '../generated/settings/settings';
import { isDev } from './EnvironmentUtils';
import { getBasePath } from './HistoryUtils';
import { setOidcToken } from './LocalStorageUtils';
const cookieStorage = new CookieStorage();
@ -42,7 +42,7 @@ const cookieStorage = new CookieStorage();
// 1 minutes for client auth approach
export const EXPIRY_THRESHOLD_MILLES = 1 * 60 * 1000;
const subPath = process.env.APP_SUB_PATH ?? '';
const subPath = getBasePath();
export const getRedirectUri = (callbackUrl: string) => {
return isDev()

View File

@ -12,14 +12,15 @@
*/
import { createBrowserHistory } from 'history';
import process from 'process';
const subPath = process.env.APP_SUB_PATH ?? '';
export const getBasePath = () => {
return window.BASE_PATH?.slice(0, -1) ?? '';
};
export const history = createBrowserHistory(
subPath
getBasePath()
? {
basename: subPath,
basename: getBasePath(),
}
: {}
);

View File

@ -13,7 +13,6 @@
import { isUndefined } from 'lodash';
import { ServiceTypes } from 'Models';
import process from 'process';
import QueryString from 'qs';
import {
IN_PAGE_SEARCH_ROUTES,
@ -49,6 +48,7 @@ import { ProfilerDashboardType } from '../enums/table.enum';
import { PipelineType } from '../generated/api/services/ingestionPipelines/createIngestionPipeline';
import { DataQualityPageTabs } from '../pages/DataQuality/DataQualityPage.interface';
import { IncidentManagerTabs } from '../pages/IncidentManager/IncidentManager.interface';
import { getBasePath } from './HistoryUtils';
import { getPartialNameFromFQN } from './CommonUtils';
import { getServiceRouteFromServiceType } from './ServiceUtils';
import { getEncodedFqn } from './StringsUtils';
@ -608,9 +608,8 @@ export const getNotificationAlertDetailsPath = (fqn: string, tab?: string) => {
return path;
};
export const getPathNameFromWindowLocation = () => {
return window.location.pathname.replace(process.env.APP_SUB_PATH ?? '', '');
return window.location.pathname.replace(getBasePath() ?? '', '');
};
export const getTagsDetailsPath = (entityFQN: string) => {

View File

@ -18,7 +18,6 @@ const CopyWebpackPlugin = require('copy-webpack-plugin');
const process = require('process');
const outputPath = path.join(__dirname, 'build');
const subPath = process.env.APP_SUB_PATH ?? '';
const devServerTarget =
process.env.DEV_SERVER_TARGET ?? 'http://localhost:8585/';
@ -37,7 +36,7 @@ module.exports = {
// Clean the output directory before emit.
clean: true,
// Ensures bundle is served from absolute path as opposed to relative
publicPath: `${subPath}/`,
publicPath: `/`,
},
// Loaders
@ -199,11 +198,6 @@ module.exports = {
// Route all requests to index.html so that app gets to handle all copy pasted deep links
historyApiFallback: {
disableDotRule: true,
...(subPath
? {
index: `${subPath}/index.html`,
}
: {}),
},
// Proxy configuration
proxy: [

View File

@ -20,7 +20,6 @@ const TerserPlugin = require('terser-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const outputPath = path.join(__dirname, 'dist/assets');
const subPath = process.env.APP_SUB_PATH ?? '';
module.exports = {
cache: {
@ -39,10 +38,12 @@ module.exports = {
// Output configuration
output: {
path: outputPath,
filename: '[name].[contenthash].js', // Use contenthash for unique filenames
chunkFilename: '[name].[contenthash].js', // Ensure unique chunk filenames
clean: true, // Clean the output directory before emit
publicPath: `${subPath ?? ''}/`,
filename: '[name].[fullhash].js',
chunkFilename: '[name].[fullhash].js',
// Clean the output directory before emit.
clean: true,
// Ensures bundle is served from absolute path as opposed to relative
publicPath: '/',
},
// Loaders