feat: add Swagger UI docs at /api_info endpoint
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
# https://dart.dev/guides/libraries/private-files
|
# https://dart.dev/guides/libraries/private-files
|
||||||
# Created by `dart pub`
|
# Created by `dart pub`
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
/web
|
|
||||||
/build
|
/build
|
||||||
/windows
|
/windows
|
||||||
/opencode.json
|
/opencode.json
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../database/database_provider.dart';
|
|||||||
import '../middleware/auth_middleware.dart';
|
import '../middleware/auth_middleware.dart';
|
||||||
import '../middleware/request_logger.dart';
|
import '../middleware/request_logger.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
class AuthRoutes {
|
class AuthRoutes {
|
||||||
final DatabaseProvider database;
|
final DatabaseProvider database;
|
||||||
@@ -18,6 +19,7 @@ class AuthRoutes {
|
|||||||
router.post('/login', _login);
|
router.post('/login', _login);
|
||||||
router.post('/reg', _register);
|
router.post('/reg', _register);
|
||||||
router.get('/watch', AuthMiddleware(_watch).call);
|
router.get('/watch', AuthMiddleware(_watch).call);
|
||||||
|
router.get('/api_info', _apiInfo);
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,4 +114,12 @@ class AuthRoutes {
|
|||||||
logRequest(method: 'GET', url: '/watch', status: 200, duration: stopwatch.elapsed, responseHeaders: response.headers);
|
logRequest(method: 'GET', url: '/watch', status: 200, duration: stopwatch.elapsed, responseHeaders: response.headers);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Response> _apiInfo(Request request) async {
|
||||||
|
final swaggerDir = Directory('web/swagger');
|
||||||
|
final swaggerFile = File('${swaggerDir.path}/index.html');
|
||||||
|
final html = await swaggerFile.readAsString();
|
||||||
|
final response = Response(200, body: html, headers: {'Content-Type': 'text/html'});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-2
@@ -38,18 +38,17 @@ void main(List<String> args) async {
|
|||||||
final authRoutes = AuthRoutes(database);
|
final authRoutes = AuthRoutes(database);
|
||||||
final geoRoutes = GeoRoutes(database);
|
final geoRoutes = GeoRoutes(database);
|
||||||
|
|
||||||
|
|
||||||
final router = Router()
|
final router = Router()
|
||||||
..mount('/', authRoutes.routes.call)
|
..mount('/', authRoutes.routes.call)
|
||||||
..mount('/', geoRoutes.routes.call);
|
..mount('/', geoRoutes.routes.call);
|
||||||
|
|
||||||
final staticHandler = createStaticHandler('web', defaultDocument: 'index.html');
|
final staticHandler = createStaticHandler('web', defaultDocument: 'index.html');
|
||||||
final apiHandler = createStaticHandler('web/swagger', defaultDocument: 'index.html');
|
|
||||||
|
|
||||||
final handler = Pipeline()
|
final handler = Pipeline()
|
||||||
.addMiddleware(_corsMiddleware)
|
.addMiddleware(_corsMiddleware)
|
||||||
.addMiddleware(logRequests())
|
.addMiddleware(logRequests())
|
||||||
.addHandler(Cascade()
|
.addHandler(Cascade()
|
||||||
.add(apiHandler)
|
|
||||||
.add(staticHandler)
|
.add(staticHandler)
|
||||||
.add(router.call)
|
.add(router.call)
|
||||||
.handler);
|
.handler);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 665 B |
Binary file not shown.
|
After Width: | Height: | Size: 628 B |
@@ -0,0 +1,16 @@
|
|||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: -moz-scrollbars-vertical;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*:before,
|
||||||
|
*:after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!-- HTML for static distribution bundle build -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Swagger UI</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="swagger-ui.css" />
|
||||||
|
<link rel="stylesheet" type="text/css" href="index.css" />
|
||||||
|
<link rel="icon" type="image/png" href="favicon-32x32.png" sizes="32x32" />
|
||||||
|
<link rel="icon" type="image/png" href="favicon-16x16.png" sizes="16x16" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="swagger-ui-bundle.js" charset="UTF-8"> </script>
|
||||||
|
<script src="swagger-initializer.js" charset="UTF-8"> </script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en-US">
|
||||||
|
<head>
|
||||||
|
<title>Swagger UI: 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>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
window.onload = function() {
|
||||||
|
//<editor-fold desc="Changeable Configuration Block">
|
||||||
|
|
||||||
|
// the following lines will be replaced by docker/configurator, when it runs in a docker-container
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: "/swagger/swagger.yaml",
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: false,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "BaseLayout"
|
||||||
|
});
|
||||||
|
|
||||||
|
//</editor-fold>
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,346 @@
|
|||||||
|
openapi: "3.0.3"
|
||||||
|
info:
|
||||||
|
title: Family Safety Tracker API
|
||||||
|
description: REST API for sharing geolocation with family members. Authentication via JWT tokens.
|
||||||
|
version: 1.0.0
|
||||||
|
contact:
|
||||||
|
name: Family Safety Tracker
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:9090
|
||||||
|
description: Local development server
|
||||||
|
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/login:
|
||||||
|
post:
|
||||||
|
summary: Authenticate user
|
||||||
|
description: Login with login and password. Returns a JWT token on success.
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/LoginRequest"
|
||||||
|
example:
|
||||||
|
login: "john_doe"
|
||||||
|
password: "secret123"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Authentication successful
|
||||||
|
headers:
|
||||||
|
Access-Control-Allow-Origin:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/LoginResponse"
|
||||||
|
example:
|
||||||
|
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
"401":
|
||||||
|
description: Invalid credentials
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
example:
|
||||||
|
error: "Invalid credentials"
|
||||||
|
|
||||||
|
/reg:
|
||||||
|
post:
|
||||||
|
summary: Register a new user
|
||||||
|
description: Create a new user account. Password is hashed with bcrypt.
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
security: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RegisterRequest"
|
||||||
|
example:
|
||||||
|
login: "john_doe"
|
||||||
|
password: "secret123"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: User registered successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RegisterResponse"
|
||||||
|
example:
|
||||||
|
message: "User registered"
|
||||||
|
"400":
|
||||||
|
description: Bad request (e.g. login already exists)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/geo:
|
||||||
|
put:
|
||||||
|
summary: Update geolocation position
|
||||||
|
description: Update the coordinates of an existing geoposition record.
|
||||||
|
tags:
|
||||||
|
- Geolocation
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: UUID of the geoposition to update
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PositionRequest"
|
||||||
|
example:
|
||||||
|
x: 55.7558
|
||||||
|
y: 37.6173
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Position updated successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MessageResponse"
|
||||||
|
example:
|
||||||
|
message: "Position updated"
|
||||||
|
"401":
|
||||||
|
description: Unauthorized - invalid or missing token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
description: Geoposition not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/share:
|
||||||
|
post:
|
||||||
|
summary: Create a share link
|
||||||
|
description: Create a new geoposition and generate a unique share link UUID for public viewing.
|
||||||
|
tags:
|
||||||
|
- Sharing
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PositionRequest"
|
||||||
|
example:
|
||||||
|
x: 55.7558
|
||||||
|
y: 37.6173
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Share link created successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ShareResponse"
|
||||||
|
example:
|
||||||
|
geo_id: "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
share_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
"401":
|
||||||
|
description: Unauthorized - invalid or missing token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/watch:
|
||||||
|
get:
|
||||||
|
summary: Get latest position via share link
|
||||||
|
description: Retrieve the latest position for a given share link UUID. Share links are stored in-memory.
|
||||||
|
tags:
|
||||||
|
- Sharing
|
||||||
|
parameters:
|
||||||
|
- name: share_id
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: UUID of the share link
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Position retrieved successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PositionResponse"
|
||||||
|
example:
|
||||||
|
x: 55.7558
|
||||||
|
y: 37.6173
|
||||||
|
last_update: "2026-05-15T10:30:00.000Z"
|
||||||
|
expires_at: "2026-05-15T11:30:00.000Z"
|
||||||
|
"401":
|
||||||
|
description: Unauthorized - invalid or missing token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
description: Share link not found or no position available
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
examples:
|
||||||
|
linkNotFound:
|
||||||
|
summary: Share link not found
|
||||||
|
value:
|
||||||
|
error: "Share link not found"
|
||||||
|
noPosition:
|
||||||
|
summary: No position available
|
||||||
|
value:
|
||||||
|
error: "No position available"
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
BearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: JWT token obtained from /login. Payload contains user_id, login, and iss="family_safety_tracker".
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
LoginRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- login
|
||||||
|
- password
|
||||||
|
properties:
|
||||||
|
login:
|
||||||
|
type: string
|
||||||
|
description: User login / username
|
||||||
|
example: "john_doe"
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
description: User password (verified against bcrypt hash)
|
||||||
|
example: "secret123"
|
||||||
|
|
||||||
|
RegisterRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- login
|
||||||
|
- password
|
||||||
|
properties:
|
||||||
|
login:
|
||||||
|
type: string
|
||||||
|
description: Desired login / username
|
||||||
|
example: "john_doe"
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
description: Desired password (will be hashed with bcrypt)
|
||||||
|
example: "secret123"
|
||||||
|
|
||||||
|
LoginResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: JWT token for authenticated requests
|
||||||
|
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
|
||||||
|
RegisterResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "User registered"
|
||||||
|
|
||||||
|
PositionRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- x
|
||||||
|
- y
|
||||||
|
properties:
|
||||||
|
x:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
description: Longitude coordinate
|
||||||
|
example: 55.7558
|
||||||
|
y:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
description: Latitude coordinate
|
||||||
|
example: 37.6173
|
||||||
|
|
||||||
|
PositionResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
x:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
description: Longitude coordinate
|
||||||
|
example: 55.7558
|
||||||
|
y:
|
||||||
|
type: number
|
||||||
|
format: double
|
||||||
|
description: Latitude coordinate
|
||||||
|
example: 37.6173
|
||||||
|
last_update:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Timestamp of the last position update (ISO 8601)
|
||||||
|
example: "2026-05-15T10:30:00.000Z"
|
||||||
|
expires_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Expiration timestamp for the position (ISO 8601)
|
||||||
|
example: "2026-05-15T11:30:00.000Z"
|
||||||
|
|
||||||
|
ShareResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
geo_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: UUID of the created geoposition record in the database
|
||||||
|
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||||
|
share_id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: UUID of the share link for public viewing
|
||||||
|
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
|
||||||
|
MessageResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: "Position updated"
|
||||||
|
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
description: Error description
|
||||||
|
example: "Invalid credentials"
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Authentication
|
||||||
|
description: User registration and login
|
||||||
|
- name: Geolocation
|
||||||
|
description: Create and update geolocation positions
|
||||||
|
- name: Sharing
|
||||||
|
description: Share links for public position viewing
|
||||||
Reference in New Issue
Block a user