1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-31 09:24:31 +03:00

Host a Swagger UI both in the static documentation and by the server

This commit is contained in:
Quentin Gliech
2024-07-26 17:09:42 +02:00
parent 70222eeb19
commit 3f947025e2
16 changed files with 2713 additions and 106 deletions

View File

@ -14,13 +14,23 @@
use aide::{
axum::ApiRouter,
openapi::{OAuth2Flow, OAuth2Flows, OpenApi, SecurityScheme, Server, ServerVariable},
openapi::{OAuth2Flow, OAuth2Flows, OpenApi, SecurityScheme, Server},
};
use axum::{
extract::{FromRef, FromRequestParts, State},
http::HeaderName,
response::Html,
Json, Router,
};
use axum::{extract::FromRequestParts, Json, Router};
use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
use indexmap::IndexMap;
use mas_axum_utils::FancyError;
use mas_http::CorsLayerExt;
use mas_router::{OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, SimpleRoute};
use mas_router::{
ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute,
UrlBuilder,
};
use mas_templates::{ApiDocContext, Templates};
use tower_http::cors::{Any, CorsLayer};
mod call_context;
@ -35,6 +45,8 @@ pub fn router<S>() -> (OpenApi, Router<S>)
where
S: Clone + Send + Sync + 'static,
CallContext: FromRequestParts<S>,
Templates: FromRef<S>,
UrlBuilder: FromRef<S>,
{
let mut api = OpenApi::default();
let router = ApiRouter::<S>::new()
@ -70,17 +82,6 @@ where
},
)
.security_requirement_scopes("oauth2", ["urn:mas:admin"])
.server(Server {
url: "{base}".to_owned(),
variables: IndexMap::from([(
"base".to_owned(),
ServerVariable {
default: "/".to_owned(),
..ServerVariable::default()
},
)]),
..Server::default()
})
});
let router = router
@ -88,16 +89,55 @@ where
.route(
"/api/spec.json",
axum::routing::get({
let res = Json(api.clone());
move || std::future::ready(res.clone())
let api = api.clone();
move |State(url_builder): State<UrlBuilder>| {
// Let's set the servers to the HTTP base URL
let mut api = api.clone();
api.servers = vec![Server {
url: url_builder.http_base().to_string(),
..Server::default()
}];
std::future::ready(Json(api))
}
}),
)
// Serve the Swagger API reference
.route(ApiDoc::route(), axum::routing::get(swagger))
.route(
ApiDocCallback::route(),
axum::routing::get(swagger_callback),
)
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_otel_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]),
.allow_otel_headers([
AUTHORIZATION,
ACCEPT,
CONTENT_TYPE,
// Swagger will send this header, so we have to allow it to avoid CORS errors
HeaderName::from_static("x-requested-with"),
]),
);
(api, router)
}
async fn swagger(
State(url_builder): State<UrlBuilder>,
State(templates): State<Templates>,
) -> Result<Html<String>, FancyError> {
let ctx = ApiDocContext::from_url_builder(&url_builder);
let res = templates.render_swagger(&ctx)?;
Ok(Html(res))
}
async fn swagger_callback(
State(url_builder): State<UrlBuilder>,
State(templates): State<Templates>,
) -> Result<Html<String>, FancyError> {
let ctx = ApiDocContext::from_url_builder(&url_builder);
let res = templates.render_swagger_callback(&ctx)?;
Ok(Html(res))
}

View File

@ -23,6 +23,9 @@
use std::io::Write;
use aide::openapi::{Server, ServerVariable};
use indexmap::IndexMap;
/// This is a dummy state, it should never be used.
///
/// We use it to generate the API schema, which doesn't execute any request.
@ -58,10 +61,25 @@ macro_rules! impl_from_ref {
impl_from_request_parts!(mas_storage::BoxRepository);
impl_from_request_parts!(mas_storage::BoxClock);
impl_from_request_parts!(mas_handlers::BoundActivityTracker);
impl_from_ref!(mas_keystore::Keystore);
impl_from_ref!(mas_router::UrlBuilder);
impl_from_ref!(mas_templates::Templates);
fn main() -> Result<(), Box<dyn std::error::Error>> {
let (api, _) = mas_handlers::admin_api_router::<DummyState>();
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>();
// Set the server list to a configurable base URL
api.servers = vec![Server {
url: "{base}".to_owned(),
variables: IndexMap::from([(
"base".to_owned(),
ServerVariable {
default: "/".to_owned(),
..ServerVariable::default()
},
)]),
..Server::default()
}];
let mut stdout = std::io::stdout();
serde_json::to_writer_pretty(&mut stdout, &api)?;

View File

@ -870,3 +870,24 @@ pub struct GraphQLPlayground;
impl SimpleRoute for GraphQLPlayground {
const PATH: &'static str = "/graphql/playground";
}
/// `GET /api/spec.json`
pub struct ApiSpec;
impl SimpleRoute for ApiSpec {
const PATH: &'static str = "/api/spec.json";
}
/// `GET /api/doc/`
pub struct ApiDoc;
impl SimpleRoute for ApiDoc {
const PATH: &'static str = "/api/doc/";
}
/// `GET /api/doc/oauth2-callback`
pub struct ApiDocCallback;
impl SimpleRoute for ApiDocCallback {
const PATH: &'static str = "/api/doc/oauth2-callback";
}

View File

@ -28,7 +28,9 @@ pub struct UrlBuilder {
}
impl UrlBuilder {
fn absolute_url_for<U>(&self, destination: &U) -> Url
/// Create an absolute URL for a route
#[must_use]
pub fn absolute_url_for<U>(&self, destination: &U) -> Url
where
U: Route,
{

View File

@ -337,6 +337,35 @@ impl TemplateContext for AppContext {
}
}
/// Context used by the `swagger/doc.html` template
#[derive(Serialize)]
pub struct ApiDocContext {
openapi_url: Url,
callback_url: Url,
}
impl ApiDocContext {
/// Constructs a context for the API documentation page giben the
/// [`UrlBuilder`]
#[must_use]
pub fn from_url_builder(url_builder: &UrlBuilder) -> Self {
Self {
openapi_url: url_builder.absolute_url_for(&mas_router::ApiSpec),
callback_url: url_builder.absolute_url_for(&mas_router::ApiDocCallback),
}
}
}
impl TemplateContext for ApiDocContext {
fn sample(_now: chrono::DateTime<Utc>, _rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
vec![Self::from_url_builder(&url_builder)]
}
}
/// Fields of the login form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]

View File

@ -42,16 +42,16 @@ mod macros;
pub use self::{
context::{
AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext,
DeviceLinkFormField, EmailAddContext, EmailRecoveryContext, EmailVerificationContext,
EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext,
PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryExpiredContext,
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext,
UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf,
WithLanguage, WithOptionalSession, WithSession,
ApiDocContext, AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext,
DeviceLinkContext, DeviceLinkFormField, EmailAddContext, EmailRecoveryContext,
EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext,
FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext,
PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext,
ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
RegisterFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext,
UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession,
},
forms::{FieldError, FormError, FormField, FormState, ToFormState},
};
@ -324,6 +324,12 @@ register_templates! {
/// Render the frontend app
pub fn render_app(WithLanguage<AppContext>) { "app.html" }
/// Render the Swagger API reference
pub fn render_swagger(ApiDocContext) { "swagger/doc.html" }
/// Render the Swagger OAuth2 callback page
pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
/// Render the login page
pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
@ -423,6 +429,8 @@ impl Templates {
) -> anyhow::Result<()> {
check::render_not_found(self, now, rng)?;
check::render_app(self, now, rng)?;
check::render_swagger(self, now, rng)?;
check::render_swagger_callback(self, now, rng)?;
check::render_login(self, now, rng)?;
check::render_register(self, now, rng)?;
check::render_consent(self, now, rng)?;

24
docs/api/index.html Normal file
View File

@ -0,0 +1,24 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="SwaggerUI" />
<title>API documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" crossorigin></script>
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: './spec.json',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
],
});
};
</script>
</body>
</html>

View File

@ -0,0 +1,80 @@
<!DOCTYPE html>
<html lang="en">
<!-- This is taken from the swagger-ui/dist/oauth2-redirect.html file -->
<head>
<title>API documentation: OAuth2 Redirect</title>
</head>
<body>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1).replace('?', '&');
} else {
qp = location.search.substring(1);
}
arr = qp.split("&");
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value);
}
) : {};
isValid = qp.state === sentState;
if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg;
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
if (document.readyState !== 'loading') {
run();
} else {
document.addEventListener('DOMContentLoaded', function () {
run();
});
}
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -37,6 +37,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^14.1.2",
"swagger-ui-react": "^5.17.14",
"urql": "^4.1.0",
"vaul": "^0.9.1",
"zod": "^3.23.8"
@ -60,6 +61,7 @@
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-test-renderer": "^18.3.0",
"@types/swagger-ui-react": "^4.18.3",
"@vitejs/plugin-react": "^4.3.1",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.19",

35
frontend/src/swagger.tsx Normal file
View File

@ -0,0 +1,35 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// 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.
import { createRoot } from "react-dom/client";
import SwaggerUI from "swagger-ui-react";
import "swagger-ui-react/swagger-ui.css";
type ApiConfig = {
openapiUrl: string;
callbackUrl: string;
};
interface IWindow {
API_CONFIG?: ApiConfig;
}
const config = typeof window !== "undefined" && (window as IWindow).API_CONFIG;
if (!config) {
throw new Error("API_CONFIG is not defined");
}
createRoot(document.getElementById("root") as HTMLElement).render(
<SwaggerUI url={config.openapiUrl} oauth2RedirectUrl={config.callbackUrl} />,
);

View File

@ -65,6 +65,7 @@ export default defineConfig((env) => ({
resolve(__dirname, "src/main.tsx"),
resolve(__dirname, "src/shared.css"),
resolve(__dirname, "src/templates.css"),
resolve(__dirname, "src/swagger.tsx"),
],
},
},

View File

@ -5,7 +5,7 @@ set -eu
export SQLX_OFFLINE=1
BASE_DIR="$(dirname "$0")/.."
CONFIG_SCHEMA="${BASE_DIR}/docs/config.schema.json"
API_SCHEMA="${BASE_DIR}/docs/api.schema.json"
API_SCHEMA="${BASE_DIR}/docs/api/spec.json"
GRAPHQL_SCHEMA="${BASE_DIR}/frontend/schema.graphql"
POLICIES_SCHEMA="${BASE_DIR}/policies/schema/"

View File

@ -0,0 +1,35 @@
{#
Copyright 2024 The Matrix.org Foundation C.I.C.
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.
#}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>API documentation</title>
<script>
window.API_CONFIG = {
openapiUrl: "{{ openapi_url | add_slashes | safe }}",
callbackUrl: "{{ callback_url | add_slashes | safe }}",
};
</script>
{{ include_asset('src/swagger.tsx') | indent(4) | safe }}
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,97 @@
{#
Copyright 2024 The Matrix.org Foundation C.I.C.
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.
#}
{# This is taken from the swagger-ui/dist/oauth2-redirect.html file #}
<!DOCTYPE html>
<html lang="en">
<head>
<title>API documentation: OAuth2 Redirect</title>
</head>
<body>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1).replace('?', '&');
} else {
qp = location.search.substring(1);
}
arr = qp.split("&");
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value);
}
) : {};
isValid = qp.state === sentState;
if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg;
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
if (document.readyState !== 'loading') {
run();
} else {
document.addEventListener('DOMContentLoaded', function () {
run();
});
}
</script>
</body>
</html>