+ Added support for API versioning in Swagger UI

+ Added Swagger API Versioning Sample (swagger_api_versioning_primer)
This commit is contained in:
Daniele Teti 2022-04-01 16:49:29 +02:00
parent b5ddf9fe6a
commit 347c5fb2fd
19 changed files with 1766 additions and 8 deletions

View File

@ -555,9 +555,9 @@ The current beta release is named 3.2.2-nitrogen. If you want to stay on the-edg
- ⚡New! Added new default parameter to `TMVCActiveRecord.RemoveDefaultConnection` and `TMVCActiveRecord.RemoveConnection` to avoid exceptions in case of not initialized connection.
- ⚡ New! Added new default parameter to `TMVCActiveRecord.RemoveDefaultConnection` and `TMVCActiveRecord.RemoveConnection` to avoid exceptions in case of not initialized connection.
- ⚡New! Added the new `MVCOwned` attribute which allows to auto-create nested objects in the deserialization phase. This will not change the current behavior, you ned to explocitly define a property (or a field) as `MVCOwned` to allows the serialization to create or destroy object for you.
- ⚡ New! Added the new `MVCOwned` attribute which allows to auto-create nested objects in the deserialization phase. This will not change the current behavior, you ned to explocitly define a property (or a field) as `MVCOwned` to allows the serialization to create or destroy object for you.
- ✅ Improved! `Context.Data` property is now created on-demand using a lazy loading approach (expect an overall speed improvement).
@ -565,6 +565,8 @@ The current beta release is named 3.2.2-nitrogen. If you want to stay on the-edg
- ✅ Improved `MVCAREntitiesGenerator` project - now it can better handle border cases, field names which collide with Delphi keywords and a big number of tables.
- ✅ Improved error handling for JSON-RPC APIs (Thanks to [David Moorhouse](https://github.com/fastbike)). More info [here](https://github.com/danieleteti/delphimvcframework/issues/538).
- ⚡ New! Added `ActiveRecordConnectionRegistry.AddDefaultConnection(const aConnetionDefName: String)`. The connection definition **must** be known by FireDAC. This method simplifies the most common scenario shown below.
```delphi
@ -576,9 +578,9 @@ The current beta release is named 3.2.2-nitrogen. If you want to stay on the-edg
end;
```
- ⚡New! Added `ToJSONObject` and `ToJSONArray` to the `IMVCRESTResponse`. These methods automatically parse the response body and return a `TJSONObject` or a `TJSONArray` respectively. These methods work as a factory - the client code need to handle returned istances. Is the body is not compatible with the request (a.k.a. is not a JSONObject in case of `ToJSONObject`, or is not a JSONArray in case of `ToJSONArray`) an exception is raised.
- ⚡ New! Added `ToJSONObject` and `ToJSONArray` to the `IMVCRESTResponse`. These methods automatically parse the response body and return a `TJSONObject` or a `TJSONArray` respectively. These methods work as a factory - the client code need to handle returned istances. Is the body is not compatible with the request (a.k.a. is not a JSONObject in case of `ToJSONObject`, or is not a JSONArray in case of `ToJSONArray`) an exception is raised.
- ⚡New! Added `TMVCJWTBlackListMiddleware` to allow black-listing and (a sort of) logout for a JWT based authentication. This middleware **must** be registered **after** the `TMVCJWTAuthenticationMiddleware`.
- ⚡ New! Added `TMVCJWTBlackListMiddleware` to allow black-listing and (a sort of) logout for a JWT based authentication. This middleware **must** be registered **after** the `TMVCJWTAuthenticationMiddleware`.
> This middleware provides 2 events named: `OnAcceptToken` (invoked when a request contains a token - need to returns true/false if the token is still accepted by the server or not) and `OnNewJWTToBlackList` (invoked when a client ask to blacklist its current token). There is a new sample available which shows the funtionalities: `samples\middleware_jwtblacklist`.

View File

@ -0,0 +1,75 @@
unit EntitiesU;
interface
uses
MVCFramework.Serializer.Commons,
System.Generics.Collections,
MVCFramework.Swagger.Commons;
type
[MVCNameCase(ncLowerCase)]
TCustomer = class
private
fID: Integer;
fCustomerName: String;
fCountry: String;
fContactName: String;
public
function IsValid: Boolean;
[MVCSwagJSONSchemaField(stInteger, 'id', 'Customer ID', True)]
property ID: Integer read fID write fID;
[MVCSwagJSONSchemaField(stString, 'customername', 'The registered name of the customer', True)]
property CustomerName: String read fCustomerName write fCustomerName;
[MVCSwagJSONSchemaField(stString, 'contactname', 'Fullname of the customer''s contact', False)]
property ContactName: String read fContactName write fContactName;
[MVCSwagJSONSchemaField(stString, 'country', 'The country where the company is registered', True)]
property Country: String read fCountry write fCountry;
end;
[MVCNameCase(ncLowerCase)]
TCustomers = class(TObjectList<TCustomer>)
end;
function GetCustomer(const ID: Integer): TCustomer;
function GetCustomers: TCustomers;
implementation
uses
System.Math, System.SysUtils;
const
CONTACT_NAMES: array [0 .. 2] of string = ('Daniele Teti', 'Peter Parker', 'Bruce Banner');
CONTRIES: array [0 .. 2] of string = ('ITALY', 'USA', 'United Kingdom');
CUSTOMER_NAMES: array [0 .. 2] of string = ('bit Time Professionals s.r.l.', 'Spidey Ltd.', 'Green Power Corp.');
function GetCustomer(const ID: Integer): TCustomer;
begin
Result := TCustomer.Create;
Result.ID := ID;
Result.CustomerName := CUSTOMER_NAMES[Random(3)];
Result.Country := CONTRIES[Random(3)];
Result.ContactName := CONTACT_NAMES[Random(3)];
end;
function GetCustomers: TCustomers;
var
I: Integer;
begin
Result := TCustomers.Create(True);
for I := 1 to 3 do
begin
Result.Add(GetCustomer(I));
end;
end;
{ TCustomer }
function TCustomer.IsValid: Boolean;
begin
Result := not(CustomerName.IsEmpty or Country.IsEmpty);
end;
end.

View File

@ -0,0 +1,126 @@
unit MyControllerU;
interface
uses
MVCFramework, MVCFramework.Commons, MVCFramework.DataSet.Utils, MVCFramework.Swagger.Commons, EntitiesU;
type
[MVCPath('/api/v1')]
TMyControllerV1 = class(TMVCController)
public
[MVCSwagSummary('Customers', 'Get all customers', 'getCustomers')]
[MVCSwagResponses(200, 'Customers', TCustomer, True)]
[MVCPath('/customers')]
[MVCHTTPMethod([httpGET])]
procedure GetCustomers; virtual;
[MVCSwagSummary('Customers', 'Get a customer', 'getCustomerById')]
[MVCSwagParam(plPath, 'id', 'Customer ID', ptInteger, True)]
[MVCSwagResponses(200, 'Customer', TCustomer)]
[MVCSwagResponses(404, 'Customer not found', TMVCErrorResponse)]
[MVCPath('/customers/($id)')]
[MVCHTTPMethod([httpGET])]
procedure GetCustomer(id: Integer);
[MVCSwagSummary('Customers', 'Create a customer', 'createCustomers')]
[MVCSwagParam(plBody, 'Customer', 'Customer JSON Object', TCustomer)]
[MVCSwagResponses(201, 'Customer created')]
[MVCSwagResponses(HTTP_STATUS.BadRequest, 'Invalid request', TMVCErrorResponse)]
[MVCPath('/customers')]
[MVCHTTPMethod([httpPOST])]
procedure CreateCustomer;
[MVCSwagSummary('Customers', 'Update a customer', 'updateCustomer')]
[MVCSwagParam(plBody, 'Customer', 'Customer JSON Object', TCustomer, ptNotDefined, True)]
[MVCSwagParam(plPath, 'id', 'Customer ID to update', ptInteger, True)]
[MVCSwagResponses(200, 'Customer updated')]
[MVCSwagResponses(HTTP_STATUS.BadRequest, 'Invalid request', TMVCErrorResponse)]
[MVCPath('/customers/($id)')]
[MVCHTTPMethod([httpPUT])]
procedure UpdateCustomer(id: Integer);
[MVCSwagSummary('Customers', 'Delete a customer', 'deleteCustomer')]
[MVCPath('/customers/($id)')]
[MVCHTTPMethod([httpDELETE])]
procedure DeleteCustomer(id: Integer);
end;
[MVCPath('/api/v2')]
TMyControllerV2 = class(TMyControllerV1)
public
[MVCSwagSummary('Customers', 'Get all customers with extended criteria', 'getCustomersWithCriteria')]
[MVCSwagResponses(200, 'Customers', TCustomer, True)]
[MVCPath('/customers')]
[MVCHTTPMethod([httpGET])]
procedure GetCustomers; override;
end;
implementation
uses
System.SysUtils, MVCFramework.Logger, System.StrUtils, JsonDataObjects;
procedure TMyControllerV1.GetCustomers;
begin
Render<TCustomer>(EntitiesU.GetCustomers);
end;
procedure TMyControllerV1.GetCustomer(id: Integer);
begin
if id = 42 then
begin
raise EMVCException.Create(HTTP_STATUS.NotFound, 'Customer not found');
end;
Render(EntitiesU.GetCustomer(id));
end;
procedure TMyControllerV1.CreateCustomer;
var
lCustomer: TCustomer;
begin
lCustomer := Self.Context.Request.BodyAs<TCustomer>;
try
if not lCustomer.IsValid then
begin
raise EMVCException.Create(HTTP_STATUS.BadRequest, 'Customer not valid');
end;
// do something smart with lCustomer...
Render201Created();
finally
lCustomer.Free;
end;
end;
procedure TMyControllerV1.UpdateCustomer(id: Integer);
var
lCustomer: TCustomer;
begin
lCustomer := Self.Context.Request.BodyAs<TCustomer>;
try
lCustomer.ID := id; //dont be confident of the user!
if not lCustomer.IsValid then
begin
raise EMVCException.Create(HTTP_STATUS.BadRequest, 'Customer not valid');
end;
// do something smart with lCustomer...
Render(lCustomer, False);
finally
lCustomer.Free;
end;
end;
procedure TMyControllerV1.DeleteCustomer(id: Integer);
begin
ResponseStatus(200);
end;
{ TMyControllerV2 }
procedure TMyControllerV2.GetCustomers;
begin
GetCustomers;
end;
end.

View File

@ -0,0 +1,99 @@
program SwaggerPrimer;
{$APPTYPE CONSOLE}
uses
{$IFDEF MSWINDOWS}
WinAPI.ShellAPI,
WinAPI.Windows,
{$ENDIF}
System.SysUtils,
MVCFramework.Logger,
MVCFramework.Commons,
MVCFramework.REPLCommandsHandlerU,
Web.ReqMulti,
Web.WebReq,
Web.WebBroker,
IdContext,
IdHTTPWebBrokerBridge,
MyControllerU in 'MyControllerU.pas',
WebModuleU in 'WebModuleU.pas' {MyWebModule: TWebModule} ,
EntitiesU in 'EntitiesU.pas';
{$R *.res}
procedure RunServer(APort: Integer);
var
LServer: TIdHTTPWebBrokerBridge;
LCustomHandler: TMVCCustomREPLCommandsHandler;
LCmd: string;
begin
Writeln('** DMVCFramework Server ** build ' + DMVCFRAMEWORK_VERSION);
LCmd := 'start';
if ParamCount >= 1 then
LCmd := ParamStr(1);
LCustomHandler :=
function(const Value: String; const Server: TIdHTTPWebBrokerBridge; out Handled: Boolean)
: THandleCommandResult
begin
Handled := False;
Result := THandleCommandResult.Unknown;
end;
LServer := TIdHTTPWebBrokerBridge.Create(nil);
try
LServer.OnParseAuthentication := TMVCParseAuthentication.OnParseAuthentication;
LServer.DefaultPort := APort;
LServer.MaxConnections := 0;
LServer.ListenQueue := 200;
Writeln('Write "quit" or "exit" to shutdown the server');
repeat
if LCmd.IsEmpty then
begin
Write('-> ');
ReadLn(LCmd)
end;
try
case HandleCommand(LCmd.ToLower, LServer, LCustomHandler) of
THandleCommandResult.Continue:
begin
Continue;
end;
THandleCommandResult.Break:
begin
Break;
end;
THandleCommandResult.Unknown:
begin
REPLEmit('Unknown command: ' + LCmd);
end;
end;
finally
LCmd := '';
end;
until False;
finally
LServer.Free;
end;
end;
begin
ReportMemoryLeaksOnShutdown := True;
IsMultiThread := True;
try
if WebRequestHandler <> nil then
WebRequestHandler.WebModuleClass := WebModuleClass;
WebRequestHandlerProc.MaxConnections := 1024;
{$IFDEF MSWINDOWS}
ShellExecute(0, PChar('open'), PChar('http://localhost:8080/swagger'), nil, nil, sw_show);
{$ENDIF}
RunServer(8080);
except
on E: Exception do
Writeln(E.ClassName, ': ', E.Message);
end;
end.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
object MyWebModule: TMyWebModule
OnCreate = WebModuleCreate
OnDestroy = WebModuleDestroy
Actions = <>
Height = 230
Width = 415
end

View File

@ -0,0 +1,101 @@
unit WebModuleU;
interface
uses System.SysUtils,
System.Classes,
Web.HTTPApp,
MVCFramework, MVCFramework.Swagger.Commons;
type
TMyWebModule = class(TWebModule)
procedure WebModuleCreate(Sender: TObject);
procedure WebModuleDestroy(Sender: TObject);
private
FMVC: TMVCEngine;
function GetSwagInfoV1: TMVCSwaggerInfo;
function GetSwagInfoV2: TMVCSwaggerInfo;
public
{ Public declarations }
end;
var
WebModuleClass: TComponentClass = TMyWebModule;
implementation
{$R *.dfm}
uses MyControllerU, System.IOUtils, MVCFramework.Commons, MVCFramework.Middleware.Compression,
MVCFramework.Middleware.Swagger, MVCFramework.Middleware.CORS,
MVCFramework.Middleware.StaticFiles;
function TMyWebModule.GetSwagInfoV1: TMVCSwaggerInfo;
begin
Result.Title := 'DMVCFramework Swagger Sample (Version1)';
Result.Version := 'v1';
Result.Description := 'SwaggerAPI Versioning V1' + DMVCFRAMEWORK_VERSION;
Result.ContactName := 'Daniele Teti';
Result.ContactEmail := 'd.teti@bittime.it';
Result.ContactUrl := 'http://www.danieleteti.it';
Result.LicenseName := 'Apache v2';
Result.LicenseUrl := 'https://www.apache.org/licenses/LICENSE-2.0';
end;
function TMyWebModule.GetSwagInfoV2: TMVCSwaggerInfo;
begin
Result.Title := 'DMVCFramework Swagger Sample (Version2)';
Result.Version := 'v2';
Result.Description := 'SwaggerAPI Versioning V1' + DMVCFRAMEWORK_VERSION;
Result.ContactName := 'Daniele Teti';
Result.ContactEmail := 'd.teti@bittime.it';
Result.ContactUrl := 'http://www.danieleteti.it';
Result.LicenseName := 'Apache v2';
Result.LicenseUrl := 'https://www.apache.org/licenses/LICENSE-2.0';
end;
procedure TMyWebModule.WebModuleCreate(Sender: TObject);
begin
FMVC := TMVCEngine.Create(Self,
procedure(Config: TMVCConfig)
begin
// session timeout (0 means session cookie)
Config[TMVCConfigKey.SessionTimeout] := '0';
// default content-type
Config[TMVCConfigKey.DefaultContentType] := TMVCConstants.DEFAULT_CONTENT_TYPE;
// default content charset
Config[TMVCConfigKey.DefaultContentCharset] := TMVCConstants.DEFAULT_CONTENT_CHARSET;
// unhandled actions are permitted?
Config[TMVCConfigKey.AllowUnhandledAction] := 'false';
// default view file extension
Config[TMVCConfigKey.DefaultViewFileExtension] := 'html';
// view path
Config[TMVCConfigKey.ViewPath] := 'templates';
// Max Record Count for automatic Entities CRUD
Config[TMVCConfigKey.MaxEntitiesRecordCount] := '20';
// Enable Server Signature in response
Config[TMVCConfigKey.ExposeServerSignature] := 'true';
// Max request size in bytes
Config[TMVCConfigKey.MaxRequestSize] := IntToStr(TMVCConstants.DEFAULT_MAX_REQUEST_SIZE);
end);
FMVC.AddController(TMyControllerV1);
FMVC.AddController(TMyControllerV2);
FMVC.AddMiddleware(TMVCCORSMiddleware.Create);
FMVC.AddMiddleware(TMVCStaticFilesMiddleware.Create(
'/swagger', { StaticFilesPath }
'.\www', { DocumentRoot }
'index.html' { IndexDocument }
));
FMVC.AddMiddleware(TMVCSwaggerMiddleware.Create(FMVC, GetSwagInfoV1, '/api/swagger-v1.json',
'Method for authentication using JSON Web Token (JWT)', False, '','','/api/v1'));
FMVC.AddMiddleware(TMVCSwaggerMiddleware.Create(FMVC, GetSwagInfoV2, '/api/swagger-v2.json',
'Method for authentication using JSON Web Token (JWT)', False, '','','/api/v2'));
end;
procedure TMyWebModule.WebModuleDestroy(Sender: TObject);
begin
FMVC.Free;
end;
end.

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

View File

@ -0,0 +1,65 @@
<!-- 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="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;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
//url: "/api/swagger.json",
urls: [
{url:"/api/swagger-v1.json",name:"V1"},
{url:"/api/swagger-v2.json",name:"V2"}
],
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl,
SwaggerUIBundle.plugins.Topbar
],
layout: "StandaloneLayout"
});
// End Swagger UI call region
window.ui = ui;
};
</script>
</body>
</html>

View File

@ -0,0 +1,75 @@
<!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);
} 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 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();
}
window.addEventListener('DOMContentLoaded', function () {
run();
});
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -50,6 +50,7 @@ type
fEnableBasicAuthentication: Boolean;
fHost: string;
fBasePath: string;
fPathFilter: string;
procedure DocumentApiInfo(const ASwagDoc: TSwagDoc);
procedure DocumentApiSettings(AContext: TWebContext; ASwagDoc: TSwagDoc);
procedure DocumentApiAuthentication(const ASwagDoc: TSwagDoc);
@ -59,7 +60,9 @@ type
public
constructor Create(const AEngine: TMVCEngine; const ASwaggerInfo: TMVCSwaggerInfo;
const ASwaggerDocumentationURL: string = '/swagger.json'; const AJWTDescription: string = JWT_DEFAULT_DESCRIPTION;
const AEnableBasicAuthentication: Boolean = False; const AHost: string = ''; const ABasePath: string = '');
const AEnableBasicAuthentication: Boolean = False;
const AHost: string = ''; const ABasePath: string = '';
const APathFilter: String = '');
destructor Destroy; override;
procedure OnBeforeRouting(AContext: TWebContext; var AHandled: Boolean);
procedure OnBeforeControllerAction(AContext: TWebContext; const AControllerQualifiedClassName: string;
@ -93,7 +96,8 @@ uses
constructor TMVCSwaggerMiddleware.Create(const AEngine: TMVCEngine; const ASwaggerInfo: TMVCSwaggerInfo;
const ASwaggerDocumentationURL, AJWTDescription: string; const AEnableBasicAuthentication: Boolean;
const AHost, ABasePath: string);
const AHost, ABasePath: string;
const APathFilter: String);
begin
inherited Create;
fSwagDocURL := ASwaggerDocumentationURL;
@ -103,6 +107,7 @@ begin
fEnableBasicAuthentication := AEnableBasicAuthentication;
fHost := AHost;
fBasePath := ABasePath;
fPathFilter := APathFilter;
end;
destructor TMVCSwaggerMiddleware.Destroy;
@ -156,8 +161,8 @@ begin
end;
if lAttr is MVCPathAttribute then
begin
lPathAttributeFound := True;
lControllerPath := MVCPathAttribute(lAttr).Path;
lPathAttributeFound := fPathFilter.IsEmpty or lControllerPath.StartsWith(fPathFilter);
end;
if lAttr is MVCSWAGDefaultModel then
begin

View File

@ -308,7 +308,7 @@ const
'}';
JWT_DEFAULT_DESCRIPTION = 'For accessing the API a valid JWT token must be passed in all the queries ' +
'in the ''Authorization'' header.' + sLineBreak + sLineBreak +
'A valid JWT token is generated by the API and retourned as answer of a call ' + 'to the route defined ' +
'A valid JWT token is generated by the API and returned as answer of a call ' + 'to the route defined ' +
'in the JWT middleware giving a valid username and password.' + sLineBreak + sLineBreak +
'The following syntax must be used in the ''Authorization'' header :' + sLineBreak + sLineBreak +
' Bearer xxxxxx.yyyyyyy.zzzzzz' + sLineBreak;