mirror of https://github.com/grafana/grafana
Swagger: Add a custom swagger/api page (#91785)
Co-authored-by: Kristian Bremberg <kristian.bremberg@grafana.com>pull/91872/head
parent
dacf11b048
commit
427dad26a2
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,116 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
|
||||
import { SelectableValue } from '@grafana/data'; |
||||
import { Select } from '@grafana/ui'; |
||||
|
||||
import { NamespaceContext, ResourceContext } from './plugins'; |
||||
|
||||
type Props = { |
||||
value?: string; |
||||
onChange: (v?: string) => void; |
||||
|
||||
// The wrapped element
|
||||
Original: React.ElementType; |
||||
props: Record<string, unknown>; |
||||
}; |
||||
|
||||
export function K8sNameLookup(props: Props) { |
||||
const [focused, setFocus] = useState(false); |
||||
const [group, setGroup] = useState<string>(); |
||||
const [version, setVersion] = useState<string>(); |
||||
const [resource, setResource] = useState<string>(); |
||||
const [namespace, setNamespace] = useState<string>(); |
||||
const [namespaced, setNamespaced] = useState<boolean>(); |
||||
const [loading, setLoading] = useState(false); |
||||
const [options, setOptions] = useState<Array<SelectableValue<string>>>(); |
||||
const [placeholder, setPlaceholder] = useState<string>('Enter kubernetes name'); |
||||
|
||||
useEffect(() => { |
||||
if (focused && group && version && resource) { |
||||
setLoading(true); |
||||
setPlaceholder('Enter kubernetes name'); |
||||
const fn = async () => { |
||||
const url = namespaced |
||||
? `apis/${group}/${version}/namespaces/${namespace}/${resource}` |
||||
: `apis/${group}/${version}/${resource}`; |
||||
|
||||
const response = await fetch(url + '?limit=100', { |
||||
headers: { |
||||
Accept: |
||||
'application/json;as=Table;v=v1;g=meta.k8s.io,application/json;as=Table;v=v1beta1;g=meta.k8s.io,application/jso', |
||||
}, |
||||
}); |
||||
if (!response.ok) { |
||||
console.warn('error loading names'); |
||||
setLoading(false); |
||||
return; |
||||
} |
||||
const table = await response.json(); |
||||
console.log('LIST', url, table); |
||||
const options: Array<SelectableValue<string>> = []; |
||||
if (table.rows?.length) { |
||||
table.rows.forEach((row: any) => { |
||||
const n = row.object?.metadata?.name; |
||||
if (n) { |
||||
options.push({ label: n, value: n }); |
||||
} |
||||
}); |
||||
} else { |
||||
setPlaceholder('No items found'); |
||||
} |
||||
setLoading(false); |
||||
setOptions(options); |
||||
}; |
||||
fn(); |
||||
} |
||||
}, [focused, namespace, group, version, resource, namespaced]); |
||||
|
||||
return ( |
||||
<NamespaceContext.Consumer> |
||||
{(namespace) => { |
||||
return ( |
||||
<ResourceContext.Consumer> |
||||
{(info) => { |
||||
// delay avoids Cannot update a component
|
||||
setTimeout(() => { |
||||
setNamespace(namespace); |
||||
setGroup(info?.group); |
||||
setVersion(info?.version); |
||||
setResource(info?.resource); |
||||
setNamespaced(info?.namespaced); |
||||
}, 200); |
||||
if (info) { |
||||
const value = props.value ? { label: props.value, value: props.value } : undefined; |
||||
return ( |
||||
<Select |
||||
allowCreateWhileLoading={true} |
||||
allowCustomValue={true} |
||||
placeholder={placeholder} |
||||
loadingMessage="Loading kubernetes names..." |
||||
formatCreateLabel={(v) => `Use: ${v}`} |
||||
onFocus={() => { |
||||
// Delay loading until we click on the name
|
||||
setFocus(true); |
||||
}} |
||||
options={options} |
||||
isLoading={loading} |
||||
isClearable={true} |
||||
defaultOptions |
||||
value={value} |
||||
onChange={(v: SelectableValue<string>) => { |
||||
props.onChange(v?.value ?? ''); |
||||
}} |
||||
onCreateOption={(v) => { |
||||
props.onChange(v); |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
return <props.Original {...props.props} />; |
||||
}} |
||||
</ResourceContext.Consumer> |
||||
); |
||||
}} |
||||
</NamespaceContext.Consumer> |
||||
); |
||||
} |
@ -0,0 +1,105 @@ |
||||
import getDefaultMonacoLanguages from 'lib/monaco-languages'; |
||||
import { useState } from 'react'; |
||||
import { useAsync } from 'react-use'; |
||||
import SwaggerUI from 'swagger-ui-react'; |
||||
|
||||
import { createTheme, monacoLanguageRegistry, SelectableValue } from '@grafana/data'; |
||||
import { Stack, Select } from '@grafana/ui'; |
||||
import { setMonacoEnv } from 'app/core/monacoEnv'; |
||||
import { ThemeProvider } from 'app/core/utils/ConfigProvider'; |
||||
|
||||
import { NamespaceContext, WrappedPlugins } from './plugins'; |
||||
|
||||
export const Page = () => { |
||||
const theme = createTheme({ colors: { mode: 'light' } }); |
||||
const [url, setURL] = useState<SelectableValue<string>>(); |
||||
const urls = useAsync(async () => { |
||||
const v2 = { label: 'Grafana API (OpenAPI v2)', key: 'openapi2', value: 'public/api-merged.json' }; |
||||
const v3 = { label: 'Grafana API (OpenAPI v3)', key: 'openapi3', value: 'public/openapi3.json' }; |
||||
const urls: Array<SelectableValue<string>> = [v2, v3]; |
||||
|
||||
const rsp = await fetch('openapi/v3'); |
||||
const apis = await rsp.json(); |
||||
for (const [key, val] of Object.entries<any>(apis.paths)) { |
||||
const parts = key.split('/'); |
||||
if (parts.length === 3) { |
||||
urls.push({ |
||||
key: `${parts[1]}-${parts[2]}`, |
||||
label: `${parts[1]}/${parts[2]}`, |
||||
value: val.serverRelativeURL.substring(1), // remove initial slash
|
||||
}); |
||||
} |
||||
} |
||||
|
||||
let idx = 0; |
||||
const urlParams = new URLSearchParams(window.location.search); |
||||
const api = urlParams.get('api'); |
||||
if (api) { |
||||
urls.forEach((url, i) => { |
||||
if (url.key === api) { |
||||
idx = i; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
monacoLanguageRegistry.setInit(getDefaultMonacoLanguages); |
||||
setMonacoEnv(); |
||||
|
||||
setURL(urls[idx]); // Remove to start at the generic landing page
|
||||
return urls; |
||||
}); |
||||
|
||||
const namespace = useAsync(async () => { |
||||
const response = await fetch('api/frontend/settings'); |
||||
if (!response.ok) { |
||||
console.warn('No settings found'); |
||||
return ''; |
||||
} |
||||
const val = await response.json(); |
||||
return val.namespace; |
||||
}); |
||||
|
||||
return ( |
||||
<div> |
||||
<ThemeProvider value={theme}> |
||||
<NamespaceContext.Provider value={namespace.value}> |
||||
<div style={{ backgroundColor: '#000', padding: '10px' }}> |
||||
<Stack justifyContent={'space-between'}> |
||||
<img height="40" src="public/img/grafana_icon.svg" alt="Grafana" /> |
||||
<Select |
||||
options={urls.value} |
||||
isClearable={false /* TODO -- when we allow a landing page, this can be true */} |
||||
onChange={(v) => { |
||||
const url = new URL(window.location.href); |
||||
url.hash = ''; |
||||
if (v?.key) { |
||||
url.searchParams.set('api', v.key); |
||||
} else { |
||||
url.searchParams.delete('api'); |
||||
} |
||||
history.pushState(null, '', url); |
||||
setURL(v); |
||||
}} |
||||
value={url} |
||||
isLoading={urls.loading} |
||||
/> |
||||
</Stack> |
||||
</div> |
||||
|
||||
{url?.value && ( |
||||
<SwaggerUI |
||||
url={url.value} |
||||
presets={[WrappedPlugins]} |
||||
deepLinking={true} |
||||
tryItOutEnabled={true} |
||||
queryConfigEnabled={false} |
||||
persistAuthorization={false} |
||||
/> |
||||
)} |
||||
|
||||
{!url?.value && <div>...{/** TODO, we can make an api docs loading page here */}</div>} |
||||
</NamespaceContext.Provider> |
||||
</ThemeProvider> |
||||
</div> |
||||
); |
||||
}; |
@ -0,0 +1,52 @@ |
||||
import '../app/core/trustedTypePolicies'; |
||||
declare let __webpack_public_path__: string; |
||||
declare let __webpack_nonce__: string; |
||||
|
||||
// Check if we are hosting files on cdn and set webpack public path
|
||||
if (window.public_cdn_path) { |
||||
__webpack_public_path__ = window.public_cdn_path; |
||||
} |
||||
|
||||
// This is a path to the public folder without '/build'
|
||||
window.__grafana_public_path__ = |
||||
__webpack_public_path__.substring(0, __webpack_public_path__.lastIndexOf('build/')) || __webpack_public_path__; |
||||
|
||||
if (window.nonce) { |
||||
__webpack_nonce__ = window.nonce; |
||||
} |
||||
|
||||
import 'swagger-ui-react/swagger-ui.css'; |
||||
|
||||
import DOMPurify from 'dompurify'; |
||||
import { createRoot } from 'react-dom/client'; |
||||
|
||||
import { textUtil } from '@grafana/data'; |
||||
|
||||
import { Page } from './SwaggerPage'; |
||||
|
||||
// Use dom purify for the default policy
|
||||
const tt = window.trustedTypes; |
||||
if (tt?.createPolicy) { |
||||
tt.createPolicy('default', { |
||||
createHTML: (string, sink) => DOMPurify.sanitize(string, { RETURN_TRUSTED_TYPE: true }) as unknown as string, |
||||
createScriptURL: (url, sink) => textUtil.sanitizeUrl(url) as unknown as string, |
||||
createScript: (script, sink) => script, |
||||
}); |
||||
} |
||||
|
||||
window.onload = () => { |
||||
// the trailing slash breaks relative URL loading
|
||||
if (window.location.pathname.endsWith('/')) { |
||||
const idx = window.location.href.lastIndexOf('/'); |
||||
window.location.href = window.location.href.substring(0, idx); |
||||
return; |
||||
} |
||||
|
||||
const rootElement = document.getElementById('root'); |
||||
if (!rootElement) { |
||||
alert('unable to find root element'); |
||||
return; |
||||
} |
||||
const root = createRoot(rootElement); |
||||
root.render(<Page />); |
||||
}; |
@ -0,0 +1,163 @@ |
||||
import { createContext } from 'react'; |
||||
|
||||
import { CodeEditor, Monaco } from '@grafana/ui'; |
||||
|
||||
import { K8sNameLookup } from './K8sNameLookup'; |
||||
|
||||
// swagger does not have types
|
||||
interface UntypedProps { |
||||
[k: string]: any; |
||||
} |
||||
|
||||
export type SchemaType = Record<string, any> | undefined; |
||||
export type ResourceInfo = { |
||||
group: string; |
||||
version: string; |
||||
resource: string; |
||||
namespaced: boolean; |
||||
}; |
||||
|
||||
// Use react contexts to stash settings
|
||||
export const SchemaContext = createContext<SchemaType>(undefined); |
||||
export const NamespaceContext = createContext<string | undefined>(undefined); |
||||
export const ResourceContext = createContext<ResourceInfo | undefined>(undefined); |
||||
|
||||
/* eslint-disable react/display-name */ |
||||
export const WrappedPlugins = function () { |
||||
return { |
||||
wrapComponents: { |
||||
parameterRow: (Original: React.ElementType) => (props: UntypedProps) => { |
||||
// When the parameter name is in the path, lets make it a drop down
|
||||
const name = props.param.get('name'); |
||||
const where = props.param.get('in'); |
||||
if (name === 'name' && where === 'path') { |
||||
const path = props.specPath.get(1).split('/'); |
||||
if (path.length > 4 && path[1] === 'apis') { |
||||
const info: ResourceInfo = { |
||||
group: path[2], |
||||
version: path[3], |
||||
resource: path[4], |
||||
namespaced: path[4] === 'namespaces', |
||||
}; |
||||
if (info.namespaced) { |
||||
info.resource = path[6]; |
||||
} |
||||
// console.log('NAME (in path)', path, info);
|
||||
return ( |
||||
<ResourceContext.Provider value={info}> |
||||
<Original {...props} /> |
||||
</ResourceContext.Provider> |
||||
); |
||||
} |
||||
} |
||||
return <Original {...props} />; |
||||
}, |
||||
|
||||
// https://github.com/swagger-api/swagger-ui/blob/v5.17.14/src/core/components/parameters/parameters.jsx#L235
|
||||
// https://github.com/swagger-api/swagger-ui/blob/v5.17.14/src/core/plugins/oas3/components/request-body.jsx#L35
|
||||
RequestBody: (Original: React.ElementType) => (props: UntypedProps) => { |
||||
let v: SchemaType = undefined; |
||||
const content = props.requestBody.get('content'); |
||||
if (content) { |
||||
let mime = content.get('application/json') ?? content.get('*/*'); |
||||
if (mime) { |
||||
v = mime.get('schema').toJS(); |
||||
} |
||||
console.log('RequestBody', v, mime, props); |
||||
} |
||||
// console.log('RequestBody PROPS', props);
|
||||
return ( |
||||
<SchemaContext.Provider value={v}> |
||||
<Original {...props} /> |
||||
</SchemaContext.Provider> |
||||
); |
||||
}, |
||||
|
||||
modelExample: (Original: React.ElementType) => (props: UntypedProps) => { |
||||
if (props.isExecute && props.schema) { |
||||
console.log('modelExample PROPS', props); |
||||
return ( |
||||
<SchemaContext.Provider value={props.schema.toJS()}> |
||||
<Original {...props} /> |
||||
</SchemaContext.Provider> |
||||
); |
||||
} |
||||
return <Original {...props} />; |
||||
}, |
||||
|
||||
JsonSchemaForm: (Original: React.ElementType) => (props: UntypedProps) => { |
||||
const { description, disabled, required, onChange, value } = props; |
||||
if (!disabled && required) { |
||||
switch (description) { |
||||
case 'namespace': { |
||||
return ( |
||||
<NamespaceContext.Consumer> |
||||
{(namespace) => { |
||||
if (!value && namespace) { |
||||
setTimeout(() => { |
||||
// Fake type in the value
|
||||
onChange(namespace); |
||||
}, 100); |
||||
} |
||||
return <Original {...props} />; |
||||
}} |
||||
</NamespaceContext.Consumer> |
||||
); |
||||
} |
||||
case 'name': { |
||||
return <K8sNameLookup onChange={onChange} value={value} Original={Original} props={props} />; |
||||
} |
||||
} |
||||
} |
||||
return <Original {...props} />; |
||||
}, |
||||
|
||||
// https://github.com/swagger-api/swagger-ui/blob/v5.17.14/src/core/plugins/oas3/components/request-body-editor.jsx
|
||||
TextArea: (Original: React.ElementType) => (props: UntypedProps) => { |
||||
return ( |
||||
<SchemaContext.Consumer> |
||||
{(schema) => { |
||||
if (schema) { |
||||
const val = props.value ?? props.defaultValue ?? ''; |
||||
//console.log('JSON TextArea', props, info);
|
||||
// Return a synthetic text area event
|
||||
const cb = (txt: string) => { |
||||
props.onChange({ |
||||
target: { |
||||
value: txt, |
||||
}, |
||||
}); |
||||
}; |
||||
console.log('CodeEditor', schema); |
||||
|
||||
return ( |
||||
<CodeEditor |
||||
value={val} |
||||
height={300} |
||||
language="application/json" |
||||
showMiniMap={val.length > 500} |
||||
onBlur={cb} |
||||
onSave={cb} |
||||
onBeforeEditorMount={(monaco: Monaco) => { |
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ |
||||
validate: true, |
||||
schemas: [ |
||||
{ |
||||
uri: schema['$$ref'] ?? '#internal', |
||||
fileMatch: ['*'], // everything
|
||||
schema: schema, |
||||
}, |
||||
], |
||||
}); |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
return <Original {...props} />; |
||||
}} |
||||
</SchemaContext.Consumer> |
||||
); |
||||
}, |
||||
}, |
||||
}; |
||||
}; |
@ -1,110 +1,44 @@ |
||||
<!-- HTML for static distribution bundle build --> |
||||
<!doctype html> |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<title>Swagger UI</title> |
||||
<link |
||||
rel="stylesheet" |
||||
type="text/css" |
||||
href="https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui.css" |
||||
integrity="sha384-wxLW6kwyHktdDGr6Pv1zgm/VGJh99lfUbzSn6HNHBENZlCN7W602k9VkGdxuFvPn" |
||||
crossorigin="anonymous" |
||||
referrerpolicy="no-referrer" |
||||
/> |
||||
<link rel="icon" type="image/png" href="favicon-32x32.png" sizes="32x32" /> |
||||
<link rel="icon" type="image/png" href="favicon-16x16.png" sizes="16x16" /> |
||||
<style> |
||||
html { |
||||
box-sizing: border-box; |
||||
overflow: -moz-scrollbars-vertical; |
||||
overflow-y: scroll; |
||||
} |
||||
|
||||
*, |
||||
*:before, |
||||
*:after { |
||||
box-sizing: inherit; |
||||
} |
||||
|
||||
body { |
||||
margin: 0; |
||||
background: #fafafa; |
||||
} |
||||
|
||||
.swagger-ui .topbar a { |
||||
content: url('public/img/grafana_icon.svg'); |
||||
height: 50px; |
||||
flex: 0; |
||||
} |
||||
</style> |
||||
</head> |
||||
|
||||
<body> |
||||
<div id="swagger-ui"></div> |
||||
|
||||
<head> |
||||
[[ if and .CSPEnabled .IsDevelopmentEnv ]] |
||||
<!-- Cypress overwrites CSP headers in HTTP requests, so this is required for e2e tests--> |
||||
<meta http-equiv="Content-Security-Policy" content="[[.CSPContent]]"/> |
||||
[[ end ]] |
||||
<meta charset="utf-8" /> |
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" /> |
||||
<meta name="viewport" content="width=device-width" /> |
||||
<meta name="theme-color" content="#000" /> |
||||
|
||||
<title>Grafana API Reference</title> |
||||
|
||||
<link rel="stylesheet" href="[[.Assets.Light]]" /> |
||||
|
||||
<link rel="icon" type="image/png" href="[[.FavIcon]]" /> |
||||
<link rel="apple-touch-icon" sizes="180x180" href="[[.AppleTouchIcon]]" /> |
||||
<link rel="mask-icon" href="[[.Assets.ContentDeliveryURL]]public/img/grafana_mask_icon.svg" color="#F05A28" /> |
||||
</head> |
||||
|
||||
<body> |
||||
<noscript> |
||||
You need to enable JavaScript to run this app. |
||||
</noscript> |
||||
<script nonce="[[$.Nonce]]"> |
||||
[[if .Assets.ContentDeliveryURL]] |
||||
window.public_cdn_path = '[[.Assets.ContentDeliveryURL]]public/build/'; |
||||
[[end]] |
||||
</script> |
||||
<div id="root"></div> |
||||
[[range $asset := .Assets.Swagger]] |
||||
<script |
||||
nonce="[[.Nonce]]" |
||||
src="https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-bundle.js" |
||||
charset="UTF-8" |
||||
integrity="sha384-wmyclcVGX/WhUkdkATwhaK1X1JtiNrr2EoYJ+diV3vj4v6OC5yCeSu+yW13SYJep" |
||||
crossorigin="anonymous" |
||||
referrerpolicy="no-referrer" |
||||
></script> |
||||
<script |
||||
nonce="[[.Nonce]]" |
||||
src="https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-standalone-preset.js" |
||||
charset="UTF-8" |
||||
integrity="sha384-2YH8WDRaj7V2OqU/trsmzSagmk/E2SutiCsGkdgoQwC9pNUJV1u/141DHB6jgs8t" |
||||
crossorigin="anonymous" |
||||
referrerpolicy="no-referrer" |
||||
></script> |
||||
<script nonce="[[.Nonce]]"> |
||||
window.onload = async function () { |
||||
// the trailing slash breaks relative URL loading |
||||
if (window.location.pathname.endsWith('/')) { |
||||
const idx = window.location.href.lastIndexOf('/'); |
||||
window.location.href = window.location.href.substring(0, idx); |
||||
return; |
||||
} |
||||
|
||||
const urlParams = new URLSearchParams(window.location.search); |
||||
const v2 = { name: 'Grafana API (OpenAPI v2)', url: 'public/api-merged.json' }; |
||||
const v3 = { name: 'Grafana API (OpenAPI v3)', url: 'public/openapi3.json' }; |
||||
const urls = urlParams.get('show') == 'v3' ? [v3, v2] : [v2, v3]; |
||||
try { |
||||
const rsp = await fetch('openapi/v3'); |
||||
const apis = await rsp.json(); |
||||
for (const [key, value] of Object.entries(apis.paths)) { |
||||
const parts = key.split('/'); |
||||
if (parts.length == 3) { |
||||
urls.push({ |
||||
name: `${parts[1]}/${parts[2]}`, |
||||
url: value.serverRelativeURL.substring(1), // remove initial slash |
||||
}); |
||||
} |
||||
} |
||||
urls.push({ name: 'Grafana apps (OpenAPI v2)', url: 'openapi/v2' }); |
||||
} catch (err) { |
||||
// console.warn('Error loading k8s apis', err); |
||||
} |
||||
|
||||
// Begin Swagger UI call region |
||||
const ui = SwaggerUIBundle({ |
||||
urls, |
||||
dom_id: '#swagger-ui', |
||||
deepLinking: true, |
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset], |
||||
plugins: [SwaggerUIBundle.plugins.DownloadUrl], |
||||
layout: 'StandaloneLayout', |
||||
filter: true, |
||||
tagsSorter: 'alpha', |
||||
tryItOutEnabled: true, |
||||
queryConfigEnabled: true, // keeps the selected ?urls.primaryName=... |
||||
}); |
||||
|
||||
window.ui = ui; |
||||
}; |
||||
</script> |
||||
</body> |
||||
</html> |
||||
nonce="[[$.Nonce]]" |
||||
src="[[$asset.FilePath]]" |
||||
type="text/javascript" |
||||
></script> |
||||
[[end]] |
||||
<script> |
||||
</script> |
||||
</body> |
||||
|
||||
</html> |
Loading…
Reference in new issue