Added SWAGGER support also for APIs generated by the TMVCActiveRecordController

This commit is contained in:
Daniele Teti 2024-10-01 16:16:27 +02:00
parent 082089f9a1
commit 613347be1f
6 changed files with 276 additions and 27 deletions

View File

@ -710,6 +710,8 @@ type
class function CreateMVCActiveRecord<T: TMVCActiveRecord>(AQualifiedClassName: string; const AParams: TArray<TValue> = nil): T;
end;
TMVCEntityMapping = TPair<String, TMVCActiveRecordClass>;
IMVCEntitiesRegistry = interface
['{BB227BEB-A74A-4637-8897-B13BA938C07B}']
procedure AddEntity(const aURLSegment: string; const aActiveRecordClass: TMVCActiveRecordClass);
@ -721,6 +723,7 @@ type
out aMVCEntityProcessor: IMVCEntityProcessor)
: Boolean;
function GetEntities: TArray<String>;
function GetURLSegmentWithEntities: TArray<TMVCEntityMapping>;
end;
TMVCEntitiesRegistry = class(TInterfacedObject, IMVCEntitiesRegistry)
@ -738,6 +741,7 @@ type
function FindProcessorByURLSegment(const aURLSegment: string; out aMVCEntityProcessor: IMVCEntityProcessor)
: Boolean;
function GetEntities: TArray<String>;
function GetURLSegmentWithEntities: TArray<TMVCEntityMapping>;
end;
IMVCActiveRecordTableMap = interface
@ -3785,6 +3789,20 @@ begin
Result := fEntitiesDict.Keys.ToArray;
end;
function TMVCEntitiesRegistry.GetURLSegmentWithEntities: TArray<TMVCEntityMapping>;
var
lPair: TMVCEntityMapping;
i: Integer;
begin
SetLength(Result, fEntitiesDict.Count);
i := 0;
for lPair in fEntitiesDict do
begin
Result[I] := lPair;
Inc(i);
end;
end;
{ EMVCActiveRecord }
constructor EMVCActiveRecord.Create(const AMsg: string);

View File

@ -37,7 +37,7 @@ uses
FireDAC.Comp.Client,
MVCFramework.RQL.Parser,
System.Generics.Collections,
MVCFramework.Serializer.Commons;
MVCFramework.Serializer.Commons, MVCFramework.Swagger.Commons;
type
{$SCOPEDENUMS ON}
@ -57,28 +57,64 @@ type
const aURLSegment: String = ''); reintroduce; overload;
destructor Destroy; override;
function GetURLSegment: String;
[MVCPath('/($entityname)')]
[MVCHTTPMethod([httpGET])]
[MVCSwagSummary(TSwaggerConst.USE_DEFAULT_SUMMARY_TAGS, 'Retrieve a list of {singularmodel}', 'Get{pluralmodel}')]
[MVCSwagResponses(HTTP_STATUS.OK, 'List of {singularmodel}', SWAGUseDefaultControllerModel, True)]
[MVCSwagResponses(HTTP_STATUS.BadRequest, '', TMVCErrorResponse)]
[MVCSwagParam(TMVCSwagParamLocation.plQuery, 'rql', 'RQL filter used to filter the list of {singularmodel}', SWAGUseDefaultControllerModel, TMVCSwagParamType.ptString, False)]
procedure GetEntities(const entityname: string); virtual;
[MVCPath('/($entityname)/searches')]
[MVCHTTPMethod([httpGET, httpPOST])]
[MVCHTTPMethod([httpGET])]
[MVCSwagSummary(TSwaggerConst.USE_DEFAULT_SUMMARY_TAGS, 'Searches through {pluralmodel} and returns a list of {singularmodel}', 'Get{pluralmodel}BySearch')]
[MVCSwagResponses(HTTP_STATUS.OK, 'List of {singularmodel}', SWAGUseDefaultControllerModel, True)]
[MVCSwagResponses(HTTP_STATUS.BadRequest, '', TMVCErrorResponse)]
[MVCSwagParam(TMVCSwagParamLocation.plQuery, 'rql', 'RQL filter used to filter the list of {singularmodel}', SWAGUseDefaultControllerModel, TMVCSwagParamType.ptString, False)]
procedure GetEntitiesByRQL(const entityname: string); virtual;
[MVCPath('/($entityname)/searches')]
[MVCHTTPMethod([httpPOST])]
[MVCSwagSummary(TSwaggerConst.USE_DEFAULT_SUMMARY_TAGS, 'Searches through {pluralmodel} and returns a list of {singularmodel}', 'Get{pluralmodel}BySearchAsPOST')]
[MVCSwagResponses(HTTP_STATUS.OK, 'List of {singularmodel}', SWAGUseDefaultControllerModel, True)]
[MVCSwagResponses(HTTP_STATUS.BadRequest, '', TMVCErrorResponse)]
[MVCSwagParam(TMVCSwagParamLocation.plQuery, 'rql', 'RQL filter used to filter the list of {singularmodel}', SWAGUseDefaultControllerModel, TMVCSwagParamType.ptString, False)]
procedure GetEntitiesByRQLwithPOST(const entityname: string); virtual;
[MVCPath('/($entityname)/($id)')]
[MVCHTTPMethod([httpGET])]
[MVCSwagSummary(TSwaggerConst.USE_DEFAULT_SUMMARY_TAGS, 'Gets a {singularmodel} entity or 404 not found', 'Get{singularmodel}ByID')]
[MVCSwagResponses(HTTP_STATUS.OK, 'One {singularmodel}', SWAGUseDefaultControllerModel)]
[MVCSwagResponses(HTTP_STATUS.NotFound, 'Error', TMVCErrorResponse)]
[MVCSwagResponses(HTTP_STATUS.BadRequest, '', TMVCErrorResponse)]
procedure GetEntity(const entityname: string; const id: Integer); virtual;
[MVCPath('/($entityname)')]
[MVCHTTPMethod([httpPOST])]
[MVCSwagSummary(TSwaggerConst.USE_DEFAULT_SUMMARY_TAGS, 'Creates a {singularmodel} and returns a new id', 'Create{singularmodel}')]
[MVCSwagResponses(HTTP_STATUS.Created, 'One {singularmodel}', '')]
[MVCSwagResponses(HTTP_STATUS.NotFound, 'Error', TMVCErrorResponse)]
[MVCSwagResponses(HTTP_STATUS.BadRequest, '', TMVCErrorResponse)]
[MVCSwagParam(TMVCSwagParamLocation.plBody, '{singularmodel}', 'A single entity of type {singularmodel}', SWAGUseDefaultControllerModel, TMVCSwagParamType.ptString, True)]
procedure CreateEntity(const entityname: string); virtual;
[MVCPath('/($entityname)/($id)')]
[MVCHTTPMethod([httpPUT])]
[MVCSwagSummary(TSwaggerConst.USE_DEFAULT_SUMMARY_TAGS, 'Updates a {singularmodel} by id', 'Update{singularmodel}ByID')]
[MVCSwagResponses(HTTP_STATUS.OK, 'One {singularmodel}', SWAGUseDefaultControllerModel)]
[MVCSwagResponses(HTTP_STATUS.NotFound, 'Error', TMVCErrorResponse)]
[MVCSwagResponses(HTTP_STATUS.BadRequest, '', TMVCErrorResponse)]
[MVCSwagParam(TMVCSwagParamLocation.plBody, '{singularmodel}', 'A single entity of type {singularmodel}', SWAGUseDefaultControllerModel, TMVCSwagParamType.ptString, True)]
procedure UpdateEntity(const entityname: string; const id: Integer); virtual;
[MVCPath('/($entityname)/($id)')]
[MVCHTTPMethod([httpDELETE])]
[MVCSwagSummary(TSwaggerConst.USE_DEFAULT_SUMMARY_TAGS, 'Deletes a {singularmodel} by id', 'Delete{singularmodel}ByID')]
[MVCSwagResponses(HTTP_STATUS.NoContent, '')]
[MVCSwagResponses(HTTP_STATUS.NotFound, 'Error', TMVCErrorResponse)]
[MVCSwagResponses(HTTP_STATUS.BadRequest, '', TMVCErrorResponse)]
procedure DeleteEntity(const entityname: string; const id: Integer); virtual;
end;
@ -184,12 +220,15 @@ begin
end;
procedure TMVCActiveRecordController.GetEntitiesByRQL(const entityname: string);
begin
GetEntities(entityname);
end;
procedure TMVCActiveRecordController.GetEntitiesByRQLwithPOST(const entityname: string);
var
lRQL: string;
lJSON: TJsonObject;
begin
if Context.Request.HTTPMethod = httpPOST then
begin
lJSON := TJsonObject.Parse(Context.Request.Body) as TJsonObject;
try
if Assigned(lJSON) then
@ -204,10 +243,10 @@ begin
lJSON.Free;
end;
Context.Request.QueryStringParams.Values['rql'] := lRQL;
end;
GetEntities(entityname);
end;
procedure TMVCActiveRecordController.GetEntity(const entityname: string; const id: Integer);
var
lAR: TMVCActiveRecord;
@ -264,6 +303,11 @@ begin
Result := StrToIntDef(Config[TMVCConfigKey.MaxEntitiesRecordCount], 20);
end;
function TMVCActiveRecordController.GetURLSegment: String;
begin
Result := fURLSegment;
end;
function TMVCActiveRecordController.CheckAuthorization(aClass: TMVCActiveRecordClass;
aAction: TMVCActiveRecordAction): Boolean;
begin

View File

@ -57,6 +57,7 @@ type
procedure DocumentApiSettings(AContext: TWebContext; ASwagDoc: TSwagDoc);
procedure DocumentApiAuthentication(const ASwagDoc: TSwagDoc);
procedure DocumentApi(ASwagDoc: TSwagDoc);
procedure DocumentActiveRecordControllerApi(ASwagDoc: TSwagDoc);
procedure SortApiPaths(ASwagDoc: TSwagDoc);
procedure InternalRender(AContent: string; AContext: TWebContext);
public
@ -92,6 +93,7 @@ uses
Swag.Doc.Path.Operation,
Swag.Doc.Path.Operation.Response,
MVCFramework.Middleware.JWT,
MVCFramework.ActiveRecordController,
Swag.Doc.Path.Operation.RequestParameter,
Swag.Doc.SecurityDefinitionApiKey,
Swag.Doc.SecurityDefinitionBasic,
@ -99,7 +101,9 @@ uses
System.Generics.Collections,
System.Generics.Defaults,
System.TypInfo,
Json.Common.Helpers;
MVCFramework.Serializer.Commons,
Json.Common.Helpers,
MVCFramework.ActiveRecord;
{ TMVCSwaggerMiddleware }
@ -127,6 +131,166 @@ begin
inherited Destroy;
end;
procedure TMVCSwaggerMiddleware.DocumentActiveRecordControllerApi(ASwagDoc: TSwagDoc);
var
lRttiContext: TRttiContext;
lObjType: TRttiType;
lController: TMVCControllerDelegate;
lSwagPath: TSwagPath;
lAttr: TCustomAttribute;
lControllerPath: string;
lMethodPath: string;
lMethod: TRttiMethod;
lFoundAttr: Boolean;
lMVCHttpMethods: TMVCHTTPMethods;
lSwagPathOp: TSwagPathOperation;
I: TMVCHTTPMethodType;
lPathUri: string;
lIndex: Integer;
lAuthTypeName: string;
lIsIgnoredPath: Boolean;
lControllerDefaultModelClass: TClass;
lControllerDefaultSummaryTags: TArray<string>;
lPathAttributeFound: Boolean;
lVisitedMethodSignatures: TList<String>;
lMethodSignature: string;
lControllerDefaultModelSingularName: string;
lControllerDefaultModelPluralName: string;
lEntitiesMapping: TArray<TMVCEntityMapping>;
lPrefixURLSegment: string;
lEntityMapping: TMVCEntityMapping;
begin
lVisitedMethodSignatures := TList<String>.Create;
try
lRttiContext := TRttiContext.Create;
try
for lController in fEngine.Controllers do
begin
lControllerPath := '';
SetLength(lControllerDefaultSummaryTags, 0);
lPathAttributeFound := False;
lObjType := lRttiContext.GetType(lController.Clazz);
//Automatic API generated by TMVCActiveRecordController
if not lController.Clazz.InheritsFrom(TMVCActiveRecordController) then
begin
Continue;
end;
lEntitiesMapping := ActiveRecordMappingRegistry.GetURLSegmentWithEntities;
lPrefixURLSegment := lController.URLSegment;
for lEntityMapping in lEntitiesMapping do
begin
lControllerPath := lPrefixURLSegment;
lControllerDefaultModelClass := lEntityMapping.Value;
lControllerDefaultModelSingularName := lEntityMapping.Value.ClassName;
lControllerDefaultModelPluralName := lEntityMapping.Key;
SetLength(lControllerDefaultSummaryTags, 1);
lControllerDefaultSummaryTags[0] := TMVCSerializerHelper.ApplyNameCase(ncPascalCase, lEntityMapping.Key);
for lMethod in lObjType.GetMethods do
begin
{only public and published methods are inspected}
if not (lMethod.Visibility in [mvPublished, mvPublic]) then
begin
Continue;
end;
lIsIgnoredPath := False;
lFoundAttr := False;
lMVCHttpMethods := [];
lMethodPath := '';
for lAttr in lMethod.GetAttributes do
begin
if lAttr is MVCSwagIgnorePathAttribute then
begin
lIsIgnoredPath := True;
end;
if lAttr is MVCPathAttribute then
begin
lMethodPath := MVCPathAttribute(lAttr).Path;
lMethodPath := lMethodPath.Replace('($entityname)', lEntityMapping.Key, [rfReplaceAll]);
lFoundAttr := True;
end;
if lAttr is MVCHTTPMethodsAttribute then
begin
lMVCHttpMethods := MVCHTTPMethodsAttribute(lAttr).MVCHTTPMethods;
end;
end;
if (not lIsIgnoredPath) and lFoundAttr then
begin
lMethodSignature := lObjType.Name + '.' + lEntityMapping.Key + '_' + lMethod.Name;
if lVisitedMethodSignatures.Contains(lMethodSignature) then
begin
Continue;
end
else
begin
lVisitedMethodSignatures.Add(lMethodSignature);
end;
//LogI(lObjType.Name + '.' + lMethod.Name + ' ' + lMethod.Parent.ToString);
lSwagPath := nil;
lPathUri := TMVCSwagger.MVCPathToSwagPath(lControllerPath + lMethodPath);
for lIndex := 0 to Pred(ASwagDoc.Paths.Count) do
begin
if SameText(ASwagDoc.Paths[lIndex].Uri, lPathUri) then
begin
lSwagPath := ASwagDoc.Paths[lIndex];
Break;
end;
end;
if not Assigned(lSwagPath) then
begin
lSwagPath := TSwagPath.Create;
lSwagPath.Uri := lPathUri;
ASwagDoc.Paths.Add(lSwagPath);
end;
for I in lMVCHttpMethods do
begin
lSwagPathOp := TSwagPathOperation.Create;
TMVCSwagger.FillOperationSummary(
lSwagPathOp,
lMethod,
ASwagDoc.Definitions,
I,
lControllerDefaultModelClass,
lControllerDefaultModelSingularName,
lControllerDefaultModelPluralName,
lControllerDefaultSummaryTags);
if TMVCSwagger.MethodRequiresAuthentication(lMethod, lObjType, lAuthTypeName) then
begin
lSwagPathOp.Security.Add(lAuthTypeName);
end;
lSwagPathOp.Parameters.AddRange(
TMVCSwagger.GetParamsFromMethod(
lSwagPath.Uri,
lMethod,
ASwagDoc.Definitions,
lControllerDefaultModelClass,
lControllerDefaultModelSingularName,
lControllerDefaultModelPluralName)
);
lSwagPathOp.Operation := TMVCSwagger.MVCHttpMethodToSwagPathOperation(I);
lSwagPath.Operations.Add(lSwagPathOp);
end;
end;
end;
end;
end;
finally
lRttiContext.Free;
end;
finally
lVisitedMethodSignatures.Free;
end;
end;
procedure TMVCSwaggerMiddleware.DocumentApi(ASwagDoc: TSwagDoc);
var
lRttiContext: TRttiContext;
@ -462,6 +626,7 @@ begin
DocumentApiInfo(LSwagDoc);
DocumentApiSettings(AContext, LSwagDoc);
DocumentApiAuthentication(LSwagDoc);
DocumentActiveRecordControllerApi(LSwagDoc);
DocumentApi(LSwagDoc);
SortApiPaths(LSwagDoc);

View File

@ -921,7 +921,11 @@ begin
lSwagResponse := TSwagResponse.Create;
lSwagResponse.StatusCode := lSwagResponsesAttr.StatusCode.ToString;
lSwagResponse.Description := lSwagResponsesAttr.Description;
//lSwagResponse.Description := lSwagResponsesAttr.Description;
lSwagResponse.Description := ApplyModelName(
lSwagResponsesAttr.Description,
aControllerDefaultModelSingularName,
aControllerDefaultModelPluralName);
if not lSwagResponsesAttr.JsonSchema.IsEmpty then
begin
lSwagResponse.Schema.JsonSchema := TJSONObject.ParseJSONValue(lSwagResponsesAttr.JsonSchema) as TJSONObject

View File

@ -771,6 +771,10 @@ type
function BadRequestResponse: IMVCResponse; overload;
function BadRequestResponse(const Message: String): IMVCResponse; overload;
function UnprocessableContentResponse(const Error: TObject): IMVCResponse; overload;
function UnprocessableContentResponse: IMVCResponse; overload;
function UnprocessableContentResponse(const Message: String): IMVCResponse; overload;
function CreatedResponse(const Location: string = ''; const Body: TObject = nil): IMVCResponse; overload;
function CreatedResponse(const Location: string; const Message: String): IMVCResponse; overload;
@ -4023,11 +4027,6 @@ begin
'Hint: Messaging extensions require a valid clientid. Did you call /messages/clients/YOUR_CLIENT_ID ?');
end;
function TMVCRenderer.BadRequestResponse: IMVCResponse;
begin
Result := StatusCodeResponse(HTTP_STATUS.BadRequest, nil);
end;
function TMVCRenderer.AcceptedResponse(const Location: string;
const Body: TObject): IMVCResponse;
var
@ -4045,6 +4044,11 @@ begin
Result := lRespBuilder.StatusCode(HTTP_STATUS.Accepted).Build;
end;
function TMVCRenderer.BadRequestResponse: IMVCResponse;
begin
Result := StatusCodeResponse(HTTP_STATUS.BadRequest, nil);
end;
function TMVCRenderer.BadRequestResponse(const Error: TObject): IMVCResponse;
begin
Result := StatusCodeResponse(HTTP_STATUS.BadRequest, Error);
@ -4055,6 +4059,22 @@ begin
Result := StatusCodeResponse(HTTP_STATUS.BadRequest, nil, Message);
end;
function TMVCRenderer.UnprocessableContentResponse: IMVCResponse;
begin
Result := StatusCodeResponse(HTTP_STATUS.UnprocessableEntity, nil);
end;
function TMVCRenderer.UnprocessableContentResponse(const Error: TObject): IMVCResponse;
begin
Result := StatusCodeResponse(HTTP_STATUS.UnprocessableEntity, Error);
end;
function TMVCRenderer.UnprocessableContentResponse(const Message: String): IMVCResponse;
begin
Result := StatusCodeResponse(HTTP_STATUS.UnprocessableEntity, nil, Message);
end;
function TMVCRenderer.ConflictResponse: IMVCResponse;
begin
Result := StatusCodeResponse(HTTP_STATUS.Conflict, nil);

View File

@ -1565,8 +1565,6 @@ var
lFunc: TTProTemplateFunction;
lAnonFunc: TTProTemplateAnonFunction;
lIntegerPar1: Integer;
lInt64Value: Int64;
lExtendedValue: Double;
begin
aFunctionName := lowercase(aFunctionName);
if SameText(aFunctionName, 'gt') then