Skip to content
This repository was archived by the owner on Sep 10, 2024. It is now read-only.

Commit 69a0a7d

Browse files
committed
Host a Swagger UI both in the static documentation and by the server
1 parent 4a275fa commit 69a0a7d

File tree

16 files changed

+2891
-284
lines changed

16 files changed

+2891
-284
lines changed

crates/handlers/src/admin/mod.rs

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,23 @@
1414

1515
use aide::{
1616
axum::ApiRouter,
17-
openapi::{OAuth2Flow, OAuth2Flows, OpenApi, SecurityScheme, Server, ServerVariable},
17+
openapi::{OAuth2Flow, OAuth2Flows, OpenApi, SecurityScheme, Server},
18+
};
19+
use axum::{
20+
extract::{FromRef, FromRequestParts, State},
21+
http::HeaderName,
22+
response::Html,
23+
Json, Router,
1824
};
19-
use axum::{extract::FromRequestParts, Json, Router};
2025
use hyper::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE};
2126
use indexmap::IndexMap;
27+
use mas_axum_utils::FancyError;
2228
use mas_http::CorsLayerExt;
23-
use mas_router::{OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, SimpleRoute};
29+
use mas_router::{
30+
ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute,
31+
UrlBuilder,
32+
};
33+
use mas_templates::{ApiDocContext, Templates};
2434
use tower_http::cors::{Any, CorsLayer};
2535

2636
mod call_context;
@@ -35,6 +45,8 @@ pub fn router<S>() -> (OpenApi, Router<S>)
3545
where
3646
S: Clone + Send + Sync + 'static,
3747
CallContext: FromRequestParts<S>,
48+
Templates: FromRef<S>,
49+
UrlBuilder: FromRef<S>,
3850
{
3951
let mut api = OpenApi::default();
4052
let router = ApiRouter::<S>::new()
@@ -70,34 +82,62 @@ where
7082
},
7183
)
7284
.security_requirement_scopes("oauth2", ["urn:mas:admin"])
73-
.server(Server {
74-
url: "{base}".to_owned(),
75-
variables: IndexMap::from([(
76-
"base".to_owned(),
77-
ServerVariable {
78-
default: "/".to_owned(),
79-
..ServerVariable::default()
80-
},
81-
)]),
82-
..Server::default()
83-
})
8485
});
8586

8687
let router = router
8788
// Serve the OpenAPI spec as JSON
8889
.route(
8990
"/api/spec.json",
9091
axum::routing::get({
91-
let res = Json(api.clone());
92-
move || std::future::ready(res.clone())
92+
let api = api.clone();
93+
move |State(url_builder): State<UrlBuilder>| {
94+
// Let's set the servers to the HTTP base URL
95+
let mut api = api.clone();
96+
api.servers = vec![Server {
97+
url: url_builder.http_base().to_string(),
98+
..Server::default()
99+
}];
100+
101+
std::future::ready(Json(api))
102+
}
93103
}),
94104
)
105+
// Serve the Swagger API reference
106+
.route(ApiDoc::route(), axum::routing::get(swagger))
107+
.route(
108+
ApiDocCallback::route(),
109+
axum::routing::get(swagger_callback),
110+
)
95111
.layer(
96112
CorsLayer::new()
97113
.allow_origin(Any)
98114
.allow_methods(Any)
99-
.allow_otel_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]),
115+
.allow_otel_headers([
116+
AUTHORIZATION,
117+
ACCEPT,
118+
CONTENT_TYPE,
119+
// Swagger will send this header, so we have to allow it to avoid CORS errors
120+
HeaderName::from_static("x-requested-with"),
121+
]),
100122
);
101123

102124
(api, router)
103125
}
126+
127+
async fn swagger(
128+
State(url_builder): State<UrlBuilder>,
129+
State(templates): State<Templates>,
130+
) -> Result<Html<String>, FancyError> {
131+
let ctx = ApiDocContext::from_url_builder(&url_builder);
132+
let res = templates.render_swagger(&ctx)?;
133+
Ok(Html(res))
134+
}
135+
136+
async fn swagger_callback(
137+
State(url_builder): State<UrlBuilder>,
138+
State(templates): State<Templates>,
139+
) -> Result<Html<String>, FancyError> {
140+
let ctx = ApiDocContext::from_url_builder(&url_builder);
141+
let res = templates.render_swagger_callback(&ctx)?;
142+
Ok(Html(res))
143+
}

crates/handlers/src/bin/api-schema.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323

2424
use std::io::Write;
2525

26+
use aide::openapi::{Server, ServerVariable};
27+
use indexmap::IndexMap;
28+
2629
/// This is a dummy state, it should never be used.
2730
///
2831
/// We use it to generate the API schema, which doesn't execute any request.
@@ -58,10 +61,25 @@ macro_rules! impl_from_ref {
5861
impl_from_request_parts!(mas_storage::BoxRepository);
5962
impl_from_request_parts!(mas_storage::BoxClock);
6063
impl_from_request_parts!(mas_handlers::BoundActivityTracker);
61-
impl_from_ref!(mas_keystore::Keystore);
64+
impl_from_ref!(mas_router::UrlBuilder);
65+
impl_from_ref!(mas_templates::Templates);
6266

6367
fn main() -> Result<(), Box<dyn std::error::Error>> {
64-
let (api, _) = mas_handlers::admin_api_router::<DummyState>();
68+
let (mut api, _) = mas_handlers::admin_api_router::<DummyState>();
69+
70+
// Set the server list to a configurable base URL
71+
api.servers = vec![Server {
72+
url: "{base}".to_owned(),
73+
variables: IndexMap::from([(
74+
"base".to_owned(),
75+
ServerVariable {
76+
default: "/".to_owned(),
77+
..ServerVariable::default()
78+
},
79+
)]),
80+
..Server::default()
81+
}];
82+
6583
let mut stdout = std::io::stdout();
6684
serde_json::to_writer_pretty(&mut stdout, &api)?;
6785

crates/router/src/endpoints.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,3 +870,24 @@ pub struct GraphQLPlayground;
870870
impl SimpleRoute for GraphQLPlayground {
871871
const PATH: &'static str = "/graphql/playground";
872872
}
873+
874+
/// `GET /api/spec.json`
875+
pub struct ApiSpec;
876+
877+
impl SimpleRoute for ApiSpec {
878+
const PATH: &'static str = "/api/spec.json";
879+
}
880+
881+
/// `GET /api/doc/`
882+
pub struct ApiDoc;
883+
884+
impl SimpleRoute for ApiDoc {
885+
const PATH: &'static str = "/api/doc/";
886+
}
887+
888+
/// `GET /api/doc/oauth2-callback`
889+
pub struct ApiDocCallback;
890+
891+
impl SimpleRoute for ApiDocCallback {
892+
const PATH: &'static str = "/api/doc/oauth2-callback";
893+
}

crates/router/src/url_builder.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ pub struct UrlBuilder {
2828
}
2929

3030
impl UrlBuilder {
31-
fn absolute_url_for<U>(&self, destination: &U) -> Url
31+
/// Create an absolute URL for a route
32+
#[must_use]
33+
pub fn absolute_url_for<U>(&self, destination: &U) -> Url
3234
where
3335
U: Route,
3436
{

crates/templates/src/context.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,35 @@ impl TemplateContext for AppContext {
337337
}
338338
}
339339

340+
/// Context used by the `swagger/doc.html` template
341+
#[derive(Serialize)]
342+
pub struct ApiDocContext {
343+
openapi_url: Url,
344+
callback_url: Url,
345+
}
346+
347+
impl ApiDocContext {
348+
/// Constructs a context for the API documentation page giben the
349+
/// [`UrlBuilder`]
350+
#[must_use]
351+
pub fn from_url_builder(url_builder: &UrlBuilder) -> Self {
352+
Self {
353+
openapi_url: url_builder.absolute_url_for(&mas_router::ApiSpec),
354+
callback_url: url_builder.absolute_url_for(&mas_router::ApiDocCallback),
355+
}
356+
}
357+
}
358+
359+
impl TemplateContext for ApiDocContext {
360+
fn sample(_now: chrono::DateTime<Utc>, _rng: &mut impl Rng) -> Vec<Self>
361+
where
362+
Self: Sized,
363+
{
364+
let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
365+
vec![Self::from_url_builder(&url_builder)]
366+
}
367+
}
368+
340369
/// Fields of the login form
341370
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
342371
#[serde(rename_all = "snake_case")]

crates/templates/src/lib.rs

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,16 @@ mod macros;
4242

4343
pub use self::{
4444
context::{
45-
AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext,
46-
DeviceLinkFormField, EmailAddContext, EmailRecoveryContext, EmailVerificationContext,
47-
EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
48-
LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext,
49-
PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryExpiredContext,
50-
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
51-
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
52-
SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext, UpstreamExistingLinkContext,
53-
UpstreamRegister, UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf,
54-
WithLanguage, WithOptionalSession, WithSession,
45+
ApiDocContext, AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext,
46+
DeviceLinkContext, DeviceLinkFormField, EmailAddContext, EmailRecoveryContext,
47+
EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext,
48+
FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext,
49+
PolicyViolationContext, PostAuthContext, PostAuthContextInner, ReauthContext,
50+
ReauthFormField, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
51+
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
52+
RegisterFormField, SiteBranding, SiteConfigExt, SiteFeatures, TemplateContext,
53+
UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
54+
UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage, WithOptionalSession, WithSession,
5555
},
5656
forms::{FieldError, FormError, FormField, FormState, ToFormState},
5757
};
@@ -324,6 +324,12 @@ register_templates! {
324324
/// Render the frontend app
325325
pub fn render_app(WithLanguage<AppContext>) { "app.html" }
326326

327+
/// Render the Swagger API reference
328+
pub fn render_swagger(ApiDocContext) { "swagger/doc.html" }
329+
330+
/// Render the Swagger OAuth2 callback page
331+
pub fn render_swagger_callback(ApiDocContext) { "swagger/oauth2-redirect.html" }
332+
327333
/// Render the login page
328334
pub fn render_login(WithLanguage<WithCsrf<LoginContext>>) { "pages/login.html" }
329335

@@ -423,6 +429,8 @@ impl Templates {
423429
) -> anyhow::Result<()> {
424430
check::render_not_found(self, now, rng)?;
425431
check::render_app(self, now, rng)?;
432+
check::render_swagger(self, now, rng)?;
433+
check::render_swagger_callback(self, now, rng)?;
426434
check::render_login(self, now, rng)?;
427435
check::render_register(self, now, rng)?;
428436
check::render_consent(self, now, rng)?;

docs/api/index.html

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<html lang="en">
2+
<head>
3+
<meta charset="utf-8" />
4+
<meta name="viewport" content="width=device-width, initial-scale=1" />
5+
<meta name="description" content="SwaggerUI" />
6+
<title>API documentation</title>
7+
<link rel="stylesheet" href="https://unpkg.com/[email protected]/swagger-ui.css" />
8+
</head>
9+
<body>
10+
<div id="swagger-ui"></div>
11+
<script src="https://unpkg.com/[email protected]/swagger-ui-bundle.js" crossorigin></script>
12+
<script>
13+
window.onload = () => {
14+
window.ui = SwaggerUIBundle({
15+
url: './spec.json',
16+
dom_id: '#swagger-ui',
17+
presets: [
18+
SwaggerUIBundle.presets.apis,
19+
],
20+
});
21+
};
22+
</script>
23+
</body>
24+
</html>

docs/api/oauth2-redirect.html

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<!-- This is taken from the swagger-ui/dist/oauth2-redirect.html file -->
4+
<head>
5+
<title>API documentation: OAuth2 Redirect</title>
6+
</head>
7+
<body>
8+
<script>
9+
'use strict';
10+
function run () {
11+
var oauth2 = window.opener.swaggerUIRedirectOauth2;
12+
var sentState = oauth2.state;
13+
var redirectUrl = oauth2.redirectUrl;
14+
var isValid, qp, arr;
15+
16+
if (/code|token|error/.test(window.location.hash)) {
17+
qp = window.location.hash.substring(1).replace('?', '&');
18+
} else {
19+
qp = location.search.substring(1);
20+
}
21+
22+
arr = qp.split("&");
23+
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
24+
qp = qp ? JSON.parse('{' + arr.join() + '}',
25+
function (key, value) {
26+
return key === "" ? value : decodeURIComponent(value);
27+
}
28+
) : {};
29+
30+
isValid = qp.state === sentState;
31+
32+
if ((
33+
oauth2.auth.schema.get("flow") === "accessCode" ||
34+
oauth2.auth.schema.get("flow") === "authorizationCode" ||
35+
oauth2.auth.schema.get("flow") === "authorization_code"
36+
) && !oauth2.auth.code) {
37+
if (!isValid) {
38+
oauth2.errCb({
39+
authId: oauth2.auth.name,
40+
source: "auth",
41+
level: "warning",
42+
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
43+
});
44+
}
45+
46+
if (qp.code) {
47+
delete oauth2.state;
48+
oauth2.auth.code = qp.code;
49+
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
50+
} else {
51+
let oauthErrorMsg;
52+
if (qp.error) {
53+
oauthErrorMsg = "["+qp.error+"]: " +
54+
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
55+
(qp.error_uri ? "More info: "+qp.error_uri : "");
56+
}
57+
58+
oauth2.errCb({
59+
authId: oauth2.auth.name,
60+
source: "auth",
61+
level: "error",
62+
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
63+
});
64+
}
65+
} else {
66+
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
67+
}
68+
window.close();
69+
}
70+
71+
if (document.readyState !== 'loading') {
72+
run();
73+
} else {
74+
document.addEventListener('DOMContentLoaded', function () {
75+
run();
76+
});
77+
}
78+
</script>
79+
</body>
80+
</html>
File renamed without changes.

0 commit comments

Comments
 (0)