unit TestInsightServer;

{$mode ObjFPC}{$H+}

interface

uses
  Classes, SysUtils, types, httpdefs, syncobjs, fphttpserver, fpJSON, testinsightprotocol;

{$IF DECLARED(TCORSSUPPORT)}
{$DEFINE USECORS}
{$ENDIF}


Type
  TTestItem = Class;

  { TTestItem }

  TTestItem = Class
  Public
    TestParent : TTestItem;
    TestName : String;
    Tests : Array of TTestItem;
    TestResult : TTestInsightResult;
    Constructor Create(aParent : TTestItem; const aTestName : String);
    Destructor Destroy; override;
    Procedure FromJSON(aJSON : TJSONObject);
    Function TestPath : String;
    Function CountTestCases : Integer;
    Function IsTestSuite : Boolean;
    Function IsTestCase : Boolean;
  end;

   (*
     // Incoming
     {
       "name" : null
       "suite" : {
         "name1" : null,
         "name2" : null
       }
     }
     // Internal

      {
        "name" : { "$Result" : { }, "$Path" : "name" }
        "suite" : {
          "$path'
          name1 : { $Result : {} , "$Path" : "suite.name1" }
          name1 : { $Result : {} , "$Path" : "suite.name2"}
        }
      }
   *)


   { TTestInsightServer }

   // Object Receiver frees received object.
   TTestNamesEvent = Procedure(Sender : TObject; Tests : TTestItem) of object;
   TSelectedTestsEvent = Procedure(Sender : TObject; var Tests: TStringDynArray) of object;
   TTestResultEvent = Procedure(Sender : TObject; aResult : TTestInsightResultArray) of object;
   TTestsStartedEvent = Procedure(Sender : TObject; aCount : Integer) of object;
   TTestsOptionsEvent = Procedure(Sender : TObject; aOptions : TTestInsightOptions) of object;
   TInsightMessageType = (imtInfo,imtError);
   TTestInsightLogEvent = Procedure(Sender : TObject; const aType : TInsightMessageType; const aMessage : String) of object;

   TTestInsightServer = class(TComponent)
   private
     FBasePath: String;
     FOnClearTests: TNotifyEvent;
     FOnLog: TTestInsightLogEvent;
     FOnTestsFinished: TNotifyEvent;
     FOnTestsStarted: TTestsStartedEvent;
     FServer : TFPHttpServer;
     FOnSelectedTests: TSelectedTestsEvent;
     FOnSetTestNames: TTestNamesEvent;
     FOnTestResult: TtestResultEvent;
     FSelectedTests : String;
     FTestInsightResultClass: TTestInsightResultClass;
     FTestSuite : TTestItem;
     FTestCount : Integer;
     FInsightOptions : TTestInsightOptions;
     FOnGetOptions: TTestsOptionsEvent;
     FThread: TThread;
     FServerPort : Word;
     FServerActive : Boolean;
{$IFDEF USECORS}
     FCorsSupport : TCORSSupport;
{$ENDIF}
     procedure CreateServer;
     function ExtractResults(anArray: TJSONArray): TTestInsightResultArray;
     procedure FreeResults(Results: TTestInsightResultArray);
     function GetPort: Word;
     procedure HandleStartThreadTerminate(Sender: TObject);
     procedure SetBasePath(AValue: String);
{$IFDEF USECORS}
     procedure SetCorsSupport(AValue: TCORSSupport);
{$ENDIF}
     procedure SetPort(AValue: Word);
   Protected
     Procedure DoLog(const aType : TInsightMessageType; const aMessage : String);
     Procedure DoLog(const aType : TInsightMessageType; const Fmt : String; Args : Array of const);
     // Override if you want to create a descendent.
     function CreateTestInsightOptions: TTestInsightOptions; virtual;
     // these are called in the main thread
     procedure DoGetSelectedTests; virtual;
     procedure DoSetTestNames; virtual;
     procedure DoTestsStarted; virtual;
     procedure DoTestsFinished; virtual;
     procedure DoClearTests; virtual;
     procedure DoOptions; virtual;
     procedure Send400(aResponse: TFPHTTPConnectionResponse; aText: String);
     Procedure Send404(aResponse : TFPHTTPConnectionResponse);
     Procedure Send405(aResponse : TFPHTTPConnectionResponse);
     Procedure Send200(aResponse : TFPHTTPConnectionResponse);
     procedure SetTestList(ARequest: TFPHTTPConnectionRequest; aResponse: TFPHTTPConnectionResponse);
     procedure DoTestResults(ARequest: TFPHTTPConnectionRequest;  aResponse: TFPHTTPConnectionResponse);
     procedure ClearTestList(aResponse: TFPHTTPConnectionResponse); virtual;
     procedure DoFinishTests(aResponse: TFPHTTPConnectionResponse); virtual;
     procedure DoStartTests(aResponse: TFPHTTPConnectionResponse; aCount: Integer); virtual;
     procedure SendSelectedTestList(aResponse: TFPHTTPConnectionResponse); virtual;
     procedure DoGetOptions(aResponse: TFPHTTPConnectionResponse); virtual;
     procedure DoRequest(Sender: TObject;
       var ARequest: TFPHTTPConnectionRequest;
       var AResponse: TFPHTTPConnectionResponse);
   Public
     constructor create(aOwner : TComponent); override;
     destructor destroy; override;
     procedure StartServer;
     procedure StopServer;
     Property Active : Boolean Read FServerActive;
     // TestResultClass
     Property TestInsightResultClass : TTestInsightResultClass Read FTestInsightResultClass Write FTestInsightResultClass;
     // Options sent to the client.
     property InsightOptions : TTestInsightOptions Read FInsightOptions;
     // Port on which to listen
     property Port: Word Read GetPort Write SetPort;
     // First part of URL. By default: /tests
     Property BasePath : String Read FBasePath Write SetBasePath;
     // CORS Support ?
{$IFDEF USECORS}
     Property CorsSupport : TCORSSupport Read FCorsSupport Write SetCorsSupport;
{$ENDIF}
     // Set the list of tests. Event handler must free JSON object.
     Property OnSetTestNames : TTestNamesEvent Read FOnSetTestNames Write FOnSetTestNames;
     // Get the list of selected tests. The server will free the received object.
     Property OnGetSelectedTests : TSelectedTestsEvent Read FOnSelectedTests Write FOnSelectedTests;
     // Results of run tests. Event handler must free result array.
     Property OnTestResult : TTestResultEvent Read FOnTestResult Write FOnTestResult;
     // Tests started.
     Property OnTestsStarted : TTestsStartedEvent  Read FOnTestsStarted Write FOnTestsStarted;
     // Tests finished.
     Property OnTestsFinished : TNotifyEvent  Read FOnTestsFinished Write FOnTestsFinished;
     // Clear tests.
     Property OnClearTests : TNotifyEvent  Read FOnClearTests Write FOnClearTests;
     // Last change to set options before sending to the client. If not, Options are sent as-is.
     Property OnGetOptions : TTestsOptionsEvent Read FOnGetOptions Write FOnGetOptions;
     // Diagnostic messages
     Property OnLog : TTestInsightLogEvent Read FOnLog Write FOnLog;
   end;


implementation

Uses ssockets;

Type

  { TStartServerThread }

  TStartServerThread = Class(TThread)
  Private
    FServer : TFPHttpServer;
    FStartErrorClass: String;
    FStartErrorMessage: String;
  Public
    constructor Create(aServer: TFPHttpServer; aOnTerminate: TNotifyEvent);
    Procedure Execute; override;
    Property StartErrorMessage : String Read FStartErrorMessage;
    Property StartErrorClass : String Read FStartErrorClass;
  end;

{ TTestItem }

constructor TTestItem.Create(aParent: TTestItem; const aTestName: String);
begin
  TestParent:=aParent;
  TestName:=aTestName;
end;

destructor TTestItem.Destroy;

Var
  I : Integer;

begin
  For I:=0 to Length(Tests)-1 do
    FreeAndNil(Tests[i]);
  SetLength(Tests,0);
  FreeAndNil(TestResult);
  Inherited;
end;

procedure TTestItem.FromJSON(aJSON: TJSONObject);

Var
  I : Integer;

begin
  SetLength(Tests,aJSON.Count);
  For I:=0 to aJSON.Count-1 do
    begin
    Tests[i]:=TTestItem.Create(Self, aJSON.Names[i]);
    if aJSON.Items[i].JSONType=jtObject then
      Tests[i].FromJSON(TJSONObject(aJSON.Items[i]));
    end;
end;

function TTestItem.TestPath: String;

Var
  P : TTestItem;

begin
  if Self=Nil then exit('');
  Result:=TestName;
  P:=TestParent;
  While (P<>Nil) do
    begin
    if P.TestName<>'' then
      Result:=P.TestName+'.'+Result;
    P:=P.TestParent;
    end;
end;

function TTestItem.CountTestCases: Integer;

Var
  I : integer;

begin
  if Self=Nil then exit(0);
  Result:=0;
  for I:=0 to Length(Tests)-1 do
    Result:=Result+Tests[i].CountTestCases;
  If (Result=0) and (TestName<>'') then
    Inc(Result);
end;

function TTestItem.IsTestSuite: Boolean;
begin
  if Self=Nil then exit(False);
  Result:=Length(Tests)>0;
end;

function TTestItem.IsTestCase: Boolean;
begin
  if Self=Nil then exit(False);
  Result:=Length(Tests)=0;
end;

{ TStartServerThread }

constructor TStartServerThread.Create(aServer: TFPHttpServer; aOnTerminate : TNotifyEvent);
begin
  FServer:=aServer;
  FreeOnTerminate:=True;
  OnTerminate:=aOnTerminate;
  Inherited Create(False);
end;

procedure TStartServerThread.Execute;
begin
  try
    FServer.Active:=true;
  except
    On E : Exception do
      begin
      FStartErrorClass:=E.ClassName;
      FStartErrorMessage:=E.Message;
      end;
  end;
end;

{ TTestInsightServer }

Type

  { TTransferTestResult }

  TTransferTestResult = Class
  Private
    FEvent: TTestResultEvent;
    FSender : TObject;
    FResult : TTestInsightResultArray;
  Public
    constructor create (aEvent: TTestResultEvent; aSender : TObject; aResult : TTestInsightResultArray);
    procedure DoResultEvent;
  end;

constructor TTransferTestResult.create(aEvent: TTestResultEvent; aSender: TObject; aResult: TTestInsightResultArray);
begin
  FEvent:=aEvent;
  FSender:=aSender;
  FResult:=aResult;
end;

procedure TTransferTestResult.DoResultEvent;

begin
  if Assigned(FEvent) then
    FEvent(FSender,FResult);
end;

procedure TTestInsightServer.DoSetTestNames;
begin
  if Assigned(FOnSetTestNames) then
    FOnSetTestNames(Self,FTestSuite);
end;

procedure TTestInsightServer.DoTestsStarted;
begin
  if Assigned(FOnTestsStarted) then
    FOnTestsStarted(Self,Self.FTestCount);
end;

procedure TTestInsightServer.DoTestsFinished;
begin
  if Assigned(FOnTestsFinished) then
    FOnTestsFinished(Self);
end;

procedure TTestInsightServer.DoClearTests;
begin
  if Assigned(FOnClearTests) then
    FOnClearTests(Self);
end;

procedure TTestInsightServer.DoOptions;
begin
  if Assigned(FOnGetOptions) then
    FOnGetOptions(Self,FInsightOptions);
end;


procedure TTestInsightServer.SetTestList(ARequest: TFPHTTPConnectionRequest; aResponse: TFPHTTPConnectionResponse);

Var
  D : TJSONData;

begin
  D:=Nil;
  try
    if SameText(aRequest.ContentType,'application/json') then
      D:=GetJSON(aRequest.Content)
    else if SameText(aRequest.ContentType,'text/text') then
      D:=TestStringsToJSON(aRequest.Content)
    else
      Send400(aResponse,'Invalid content type. Need application/json or text/text');
    if (D<>Nil) and not (D is TJSONObject) then
      begin
      Send400(aResponse,'Invalid JSON, need object');
      FreeAndNil(D);
      end;
  except
    on E : exception do
      begin
      Send400(aResponse,E.Message);
      Raise;
      end;
  end;
  if D=nil then
    Exit;
  // Here we have a valid test list
  Send200(aResponse);
  if Assigned(D) then
    begin
    FTestSuite:=TtestItem.Create(Nil,'');
    FTestSuite.FromJSON(D as TJSONObject);
    if Assigned(OnSetTestNames) then
      TThread.Synchronize(TThread.CurrentThread,@DoSetTestNames)
    else
      FTestSuite.Free;
    D.Free;
    end;
end;

Function TTestInsightServer.ExtractResults(anArray : TJSONArray) : TTestInsightResultArray;

Var
  i,aLen : Integer;
  Res: TTestInsightResult;


begin
  aLen:=0;
  SetLength(Result,anArray.Count);
  For I:=0 to anArray.Count-1 do
    begin
    if anArray.Types[i]=jtObject then
      begin
      Res:=TestInsightResultClass.Create;
      try
        Res.FromJSOn(anArray.Objects[i]);
      except
        on E : Exception do
          begin
          FreeAndNil(res);
          DoLog(imtError,'Error %s extracting test result: %s',[E.ClassName, E.Message]);
          end;
      end;
      if Assigned(Res) then
        begin
        Result[aLen]:=Res;
        Inc(aLen);
        end;
      end;
    end;
end;

procedure TTestInsightServer.DoTestResults(ARequest: TFPHTTPConnectionRequest; aResponse: TFPHTTPConnectionResponse);

Var
  D : TJSONData;
  Results : TTestInsightResultArray;
  Trans : TTransferTestResult;

begin
  Results:=Nil;
  try
    D:=GetJSON(aRequest.Content);
  except
    on E : exception do
      begin
      Send400(aResponse,E.Message);
      Raise;
      end;
  end;
  try
    if D is TJSONArray then
      begin
      Send200(aResponse);
      Results:=ExtractResults(D as TJSONArray);
      end
    else if (D is TJSONObject) and (D.Count=1) and (D.Items[0] is TJSONArray) then
      begin
      Send200(aResponse);
      Results:=ExtractResults(TJSONObject(D).Extract(0) as TJSONArray);
      end
    else
      Send400(aResponse,'Bad JSON message');
  finally
    D.Free;
  end;
  if not (Assigned(Results) and Assigned(OnTestResult)) then
    Exit;
  Trans:=TTransferTestResult.create(OnTestResult,Self,Results);
  try
    TThread.Synchronize(TThread.CurrentThread,@Trans.DoResultEvent);
    FreeResults(Results);
  finally
    Trans.Free;
  end;
end;

procedure TTestInsightServer.FreeResults(Results : TTestInsightResultArray);

Var
  Res : TTestInsightResult;

begin
  For Res in Results do
    Res.Free;
  SetLength(Results,0);
end;


procedure TTestInsightServer.DoGetSelectedTests;

Var
  aTests : TStringDynArray;
  D : TJSONObject;
  A : TJSONArray;
  S : String;

begin
  aTests:=Nil;
  if Assigned(FOnSelectedTests) then
    FOnselectedTests(Self,aTests);
  A:=TJSONArray.Create;
  try
    D:=TJSONObject.Create(['tests',A]);
    For S in aTests do
      A.Add(S);
    FSelectedTests:=D.asJSON;
  Finally
    D.Free;
  end;
end;

procedure TTestInsightServer.SendSelectedTestList(aResponse: TFPHTTPConnectionResponse);
begin
  FSelectedTests:='';
  if Assigned(FOnSelectedTests) then
    TThread.Synchronize(TThread.CurrentThread,@DoGetSelectedTests);
  if FSelectedTests='' then
    FSelectedTests:='{ "tests" : [] }';
  aResponse.Content:=FSelectedTests;
  aResponse.ContentType:='application/json';
  send200(aResponse);
end;

procedure TTestInsightServer.DoGetOptions(aResponse: TFPHTTPConnectionResponse);
begin
  if Assigned(FONGetOptions) then
    TThread.Synchronize(TThread.CurrentThread,@DoOptions);
  aResponse.Content:=FInsightOptions.ToJSON;
  aResponse.ContentType:='application/json';
  send200(aResponse);
end;

procedure TTestInsightServer.ClearTestList(aResponse: TFPHTTPConnectionResponse);

begin
  Send200(aResponse);
  if Assigned(FOnClearTests) then
    TThread.Synchronize(TThread.CurrentThread,@DoClearTests);
end;

procedure TTestInsightServer.DoStartTests(aResponse: TFPHTTPConnectionResponse; aCount : Integer);

begin
  Send200(aResponse);
  FTestCount:=aCount;
  if Assigned(FOnTestsStarted) then
    TThread.Synchronize(TThread.CurrentThread,@DoTestsStarted);
end;

procedure TTestInsightServer.DoFinishTests(aResponse: TFPHTTPConnectionResponse);

begin
  Send200(aResponse);
  if Assigned(FonTestsFinished) then
    TThread.Synchronize(TThread.CurrentThread,@DoTestsFinished);
end;

procedure TTestInsightServer.DoRequest(Sender: TObject;
  var ARequest: TFPHTTPConnectionRequest;
  var AResponse: TFPHTTPConnectionResponse);

  function CheckMethod(aMethod : String) : Boolean;

  begin
    Result:=SameText(aMethod,aRequest.Method);
    if not Result then
      Send405(aResponse);
  end;

Var
  aPath : String;

begin
  aPath:=aRequest.PathInfo;
  DoLog(imtInfo,'Handling request %s %s',[aRequest.Method,aPath]);
  if not SameText(Copy(aPath,1,Length(BasePath)),BasePath) then
    Send404(aResponse)
  else
    begin
{$IFDEF USECORS}
    if FCorsSupport.HandleRequest(aRequest,aResponse,[hcDetect, hcsend]) then
      exit;
{$ENDIF}
    Delete(aPath,1,Length(BasePath));
    if (aPath='') then // '/tests'
      begin
      if SameText(aRequest.Method,'POST') then
        SetTestList(aRequest,aResponse)
      else if SameText(aRequest.Method,'get') then
        SendSelectedTestList(aResponse)
      else if SameText(aRequest.Method,'delete') then
        ClearTestList(aResponse)
      else
        Send405(aResponse)
      end
    else
      begin
      If aPath[1]='/' then
        delete(aPath,1,1);
      if SameText(aPath,pathStarted) then
        begin
        if CheckMethod('POST') then
          DoStartTests(aResponse,StrToIntDef(aRequest.QueryFields.Values[qryTotalCount],-1));
        end
      else if SameText(aPath,pathFinished) then
        begin
        if CheckMethod('POST') then
          DoFinishTests(aResponse);
        end
      else if SameText(aPath,PathResults) then
        begin
        if CheckMethod('POST') then
          DoTestResults(aRequest,aResponse);
        end
      else if SameText(aPath,PathOptions) then
        begin
        if CheckMethod('GET') then
          DoGetOptions(aResponse);
        end
      else
        Send404(aResponse);
      end;
    end;
end;

function TTestInsightServer.GetPort: Word;
begin
  if Assigned(FServer) then
    Result:=FServer.Port
  else
    Result:=FServerPort;
end;

procedure TTestInsightServer.HandleStartThreadTerminate(Sender: TObject);

var
  SThread : TStartServerThread ;
  ErrClass,ErrMsg : String;

begin
  If Sender<>FThread then exit;
  SThread:=Sender as TStartServerThread;
  ErrClass:=SThread.StartErrorClass;
  ErrMsg:=SThread.StartErrorMessage;
  if ErrClass<>'' then
    DoLog(imtError,'Error %s starting server: %s',[ErrClass,ErrMsg]);
  FThread:=Nil;
  FServerActive:=False;

end;


procedure TTestInsightServer.SetBasePath(AValue: String);

var
  len : integer;

begin
  // Make sure it starts with /
  if (AValue<>'') and (AValue[1]<>'/') then
    AValue:='/'+AValue;
  // Make sure it does not end with /
  Len:=Length(AValue);
  if (Len>1) and (AValue[Len]='/') then
    AValue:=Copy(AValue,1,Len-1)
  else if Len<2 then
    Raise ETestInsight.Create('Basepath cannot be empty');
  if FBasePath=AValue then Exit;
    FBasePath:=AValue;
end;

{$IFDEF USECORS}
procedure TTestInsightServer.SetCorsSupport(AValue: TCORSSupport);
begin
  if FCorsSupport=AValue then Exit;
  FCorsSupport.Assign(AValue);
end;
{$ENDIF}

procedure TTestInsightServer.SetPort(AValue: Word);
begin
  FServer.Port:=aValue;
  FServerPort:=aValue;
end;


procedure TTestInsightServer.DoLog(const aType : TInsightMessageType;const aMessage: String);
begin
  If Assigned(FOnLog) then
    FOnLog(Self,aType,aMessage);
end;

procedure TTestInsightServer.DoLog(const aType : TInsightMessageType;const Fmt: String; Args: array of const);
begin
  DoLog(aType,Format(Fmt,Args));
end;

procedure TTestInsightServer.Send400(aResponse: TFPHTTPConnectionResponse; aText : String);
begin
  aResponse.Code:=400;
  aResponse.CodeText:='400 BAD REQUEST';
  if aText<>'' then
    aResponse.Content:=aText
  else
    aResponse.Content:='The requested URL is invalid';
  aResponse.ContentType:='text/text';
  aResponse.SendResponse;
end;

procedure TTestInsightServer.Send404(aResponse: TFPHTTPConnectionResponse);
begin
  aResponse.Code:=404;
  aResponse.CodeText:='NOT FOUND';
  aResponse.Content:='The requested URL is invalid';
  aResponse.ContentType:='text/text';
  aResponse.SendResponse;
end;

procedure TTestInsightServer.Send405(aResponse: TFPHTTPConnectionResponse);
begin
  aResponse.Code:=405;
  aResponse.CodeText:='METHOD NOT ALLOWED';
  aResponse.Content:='The requested HTTP method is invalid';
  aResponse.ContentType:='text/text';
  aResponse.SendResponse;
end;

procedure TTestInsightServer.Send200(aResponse: TFPHTTPConnectionResponse);
begin
  aResponse.Code:=200;
  aResponse.CodeText:='OK';
  aResponse.ContentType:='application/json';
  aResponse.SendResponse;
end;

constructor TTestInsightServer.create(aOwner: TComponent);
begin
  inherited create(aOwner);
  CreateServer;
  BasePath:=pathTests;
  FTestInsightResultClass:=TTestInsightResult;
  FInsightOptions:=CreateTestInsightOptions;
{$IFDEF USECORS}
  FCorsSupport:=TCORSSupport.Create;
  FCorsSupport.Enabled:=True;
{$ENDIF}
end;


destructor TTestInsightServer.destroy;
begin
  StopServer;
  FreeAndNil(FServer);
  FreeAndNil(FInsightOptions);
{$IFDEF USECORS}
  FreeAndNil(FCorsSupport);
{$ENDIF}
  inherited destroy;
end;

procedure TTestInsightServer.CreateServer;

begin
  FServer:=TFPHttpServer.Create(Self);
  FServer.Threaded:=True;
  if FServerPort>0 then
    FServer.Port:=FServerPort;
end;

function TTestInsightServer.CreateTestInsightOptions: TTestInsightOptions;

begin
  Result:=TTestInsightOptions.Create;
end;

procedure TTestInsightServer.StartServer;
begin
  if FServerActive then
    exit;
  FServer.OnRequest:=@DoRequest;
  FServerActive:=True;
  FThread:=TStartServerThread.Create(FServer,@HandleStartThreadTerminate);
  DoLog(imtInfo,'Starting test insight server on port %d',[Port]);
end;

procedure TTestInsightServer.StopServer;
begin

  if not Assigned(FServer) then
    exit;
  if not FServer.Active then
    exit;
  FServer.OnRequest:=Nil;
  FServerActive:=False;
  DoLog(imtInfo,'Deactivating server');
  FServer.Active:=False;
  DoLog(imtInfo,'Fake request');
  Try
    TInetSocket.Create('localhost',FServer.Port,10,Nil).Free;
  except
    on E  : Exception do
     DoLog(imtError,'Fake request resulted in %s: %s',[E.ClassName,E.Message]);
  end;
  DoLog(imtInfo,'Waiting for server thread to stop');
  If Assigned(FThread) then
    FThread.WaitFor;
  DoLog(imtInfo,'Server thread stopped');
end;



end.

