gitlabclient.pas 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. {$IFNDEF FPC_DOTTEDUNITS}
  2. unit gitlabclient;
  3. {$ENDIF FPC_DOTTEDUNITS}
  4. {$mode ObjFPC}{$H+}
  5. {$modeswitch advancedrecords}
  6. interface
  7. {$IFDEF FPC_DOTTEDUNITS}
  8. uses
  9. System.Classes, System.SysUtils, System.IniFiles, FpJson.Data, FpWeb.Client, FpWeb.Client.Http, FpWeb.Http.Protocol;
  10. {$ELSE FPC_DOTTEDUNITS}
  11. uses
  12. Classes, SysUtils, inifiles, fpjson, fpwebclient, fphttpwebclient, httpprotocol;
  13. {$ENDIF FPC_DOTTEDUNITS}
  14. Const
  15. LongThrottleSleep = 60 * 1000; // One minute
  16. MaxSleepCount = 5; // max times to sleep when consecutive 429s
  17. SGitlabClient = 'Gitlab'; // Default section
  18. DefaultGitURL = 'https://gitlab.com/api/v4/';
  19. DefaultGitKey = ''; // API Key, default none
  20. DefaultProjectID = 0; // Gitlab Project ID, default none
  21. Type
  22. EGitLab = Class(Exception);
  23. { TGitlabConfig }
  24. TGitlabConfig = Record
  25. BaseURL : String;
  26. APIkey : String;
  27. ProjectID : Int64;
  28. Procedure Reset;
  29. Procedure LoadFromFile(const aFileName,aSection : String);
  30. Procedure LoadFromIni(aIni : TCustomInifile; const aSection : String);
  31. end;
  32. { TGitLabClient }
  33. TLogEvent = Procedure (Sender : TObject; Const aMessage : string) of object;
  34. TResourceCallback = procedure (Sender : TObject; aPage,aIndex,aCount : Integer; aObject : TJSONObject; aContinue : Boolean) of object;
  35. TGitLabClient = class(TObject)
  36. private
  37. FConfig: TGitlabConfig;
  38. FClient : TAbstractWebClient;
  39. FOnLog: TLogEvent;
  40. FSudo: String;
  41. procedure setconfig(AValue: TGitlabConfig);
  42. Protected
  43. procedure DoLog(const aMessage : string); overload;
  44. procedure DoLog(const aFmt : string; aArgs : Array of const); overload;
  45. function CreateRequest(aResult: TStream): TWebClientRequest;
  46. function CreateURL(aName: string; aParams: array of string; useSUDO : Boolean = False): String;
  47. procedure DoResourceRequest(aVerb, aName: String; aSrc: TStream; ADest: TStream; aContentType: String);
  48. procedure CreateResource(aName : String; aSrc : TStream; ADest : TStream; aContentType : String = '');
  49. procedure UpdateResource(aName : String; aSrc : TStream; ADest : TStream; aContentType : String = '');
  50. public
  51. Constructor Create; overload;
  52. Constructor Create(const aConfig : TGitlabConfig); overload;
  53. Destructor destroy; override;
  54. function GetProjectResourceURL(aResource: string): String;
  55. function GetResourceURL(aResource: string): String;
  56. // Upload file. URL is relative to baseURL, gets upload appended.
  57. // Return markdown
  58. Function UploadFile(const aURL,aLocalFileName,aRemoteFileName : String) : TJSONStringType;
  59. // Return JSON string
  60. function UploadFileRaw(const aURL,aLocalFileName,aRemoteFileName: string): TJSONStringType;
  61. // Return JSON Object
  62. Function UploadFileObject(const aURL,aLocalFileName,aRemoteFileName : String) : TJSONObject;
  63. // Create
  64. Function CreateResourceRaw(aName : String; aObj : TJSONObject) : TJSONStringType;
  65. Function CreateResourceObject(aName : String; aObj : TJSONObject) : TJSONObject;
  66. Function CreateResource(aName : String; aObj : TJSONObject) : Int64;
  67. // update
  68. Function UpdateResourceRaw(aName : String; aObj : TJSONObject) : TJSONStringType;
  69. Function UpdateResource(aName : String; aObj : TJSONObject) : Int64;
  70. Function UpdateResourceObject(aName : String; aObj : TJSONObject) : TJSONObject;
  71. // Get
  72. Function GetSingleResource(aName : String; aParams : array of string) : TJSONObject;
  73. Function GetResourceList(aName : String; aParams : array of string) : TJSONArray;
  74. Procedure GetResource(aName : String; aParams : array of string; aResult : TStream);
  75. Function ForEachResource(aResource : String; aParams : array of string; CallBack : TResourceCallback) : Integer;
  76. // Delete
  77. Procedure DeleteResource(aName : String);
  78. // Properties
  79. Property Config : TGitlabConfig Read FConfig write setconfig;
  80. Property OnLog : TLogEvent Read FOnLog Write FOnLog;
  81. // use SUDO
  82. Property Sudo : String Read FSudo Write FSudo;
  83. end;
  84. implementation
  85. { TGitlabConfig }
  86. procedure TGitlabConfig.Reset;
  87. begin
  88. BaseURL:=DefaultGitURL;
  89. APIKey:=DefaultGitKey;
  90. ProjectID:=DefaultProjectID;
  91. end;
  92. procedure TGitlabConfig.LoadFromFile(const aFileName, aSection: String);
  93. Var
  94. aIni : TMemIniFile;
  95. begin
  96. aIni:=TMemIniFile.Create(aFileName);
  97. try
  98. LoadFromIni(aIni,aSection);
  99. finally
  100. aIni.Free;
  101. end;
  102. end;
  103. procedure TGitlabConfig.LoadFromIni(aIni: TCustomInifile; const aSection: String);
  104. Var
  105. S : String;
  106. begin
  107. S:=aSection;
  108. if S='' then
  109. S:=SGitlabClient;
  110. BaseURL:=aIni.ReadString(S,'BaseURL',BaseURL);
  111. APIkey:=aIni.ReadString(S,'APIKey',APIKey);
  112. ProjectID:=aIni.ReadInt64(S,'ProjectID',ProjectID);
  113. end;
  114. constructor TGitLabClient.Create;
  115. begin
  116. FClient:=TFPHTTPWebClient.Create(Nil);
  117. end;
  118. constructor TGitLabClient.Create(const aConfig: TGitlabConfig);
  119. begin
  120. Create;
  121. Config:=aConfig;
  122. end;
  123. destructor TGitLabClient.destroy;
  124. begin
  125. FreeAndNil(FClient);
  126. inherited destroy;
  127. end;
  128. function TGitLabClient.UploadFile(const aURL, aLocalFileName,
  129. aRemoteFileName: String): TJSONStringType;
  130. Var
  131. Obj : TJSONObject;
  132. begin
  133. Obj:=UploadFileObject(aURL,aLocalFileName,aRemoteFilename);
  134. try
  135. Result:=Obj.Get('markdown','');
  136. finally
  137. Obj.Free;
  138. end;
  139. end;
  140. function TGitLabClient.UploadFileRaw(const aURL, aLocalFileName,
  141. aRemoteFileName: string): TJSONStringType;
  142. Const
  143. CRLF = #13#10;
  144. Var
  145. S, Sep : string;
  146. SS,SR : TRawByteStringStream;
  147. AStream : TFileStream;
  148. begin
  149. Sep:=Format('%.8x_multipart_boundary',[Random($ffffff)]);
  150. aStream:=Nil;
  151. SR:=Nil;
  152. SS:=TRawByteStringStream.Create('');
  153. try
  154. AStream:=TFileStream.Create(aLocalFileName,fmOpenRead);
  155. S:='--'+Sep+CRLF;
  156. s:=s+Format('Content-Disposition: form-data; name="%s"; filename="%s"'+CRLF,['file',aRemoteFileName]);
  157. s:=s+'Content-Type: application/octet-string'+CRLF+CRLF;
  158. SS.WriteBuffer(S[1],Length(S));
  159. AStream.Seek(0, soFromBeginning);
  160. SS.CopyFrom(AStream,AStream.Size);
  161. S:=CRLF+'--'+Sep+'--'+CRLF;
  162. SS.WriteBuffer(S[1],Length(S));
  163. SS.Position:=0;
  164. SR:=TRawByteStringStream.Create('');
  165. DoResourceRequest('POST',aURL,SS,SR,'multipart/form-data; boundary='+Sep);
  166. Result:=SR.DataString;
  167. finally
  168. SR.Free;
  169. SS.Free;
  170. aStream.Free;
  171. end;
  172. end;
  173. function TGitLabClient.UploadFileObject(const aURL, aLocalFileName,
  174. aRemoteFileName: String): TJSONObject;
  175. var
  176. aJSON : TJSONStringType;
  177. D : TJSONData;
  178. begin
  179. aJSON:=UploadFileRaw(aURL,aLocalFileName,aRemoteFileName);
  180. try
  181. D:=GetJSON(aJSON);
  182. Result:=D as TJSONObject;
  183. except
  184. on E : Exception do
  185. begin
  186. D.Free;
  187. E.Message:='Invalid JSON returned by upload of '+aLocalFileName+': '+E.Message;
  188. Raise;
  189. end;
  190. end;
  191. end;
  192. function TGitLabClient.CreateResourceRaw(aName: String; aObj: TJSONObject): TJSONStringType;
  193. Var
  194. Src,Dest : TStringStream;
  195. begin
  196. Dest:=nil;
  197. Src:=Nil;
  198. if Assigned(aObj) then
  199. Src:=TStringStream.Create(aObj.asJSON);
  200. try
  201. Dest:=TStringStream.Create('');
  202. CreateResource(aName,Src,Dest,'application/json');
  203. Result:=Dest.DataString;
  204. finally
  205. Src.Free;
  206. Dest.Free;
  207. end;
  208. end;
  209. function TGitLabClient.CreateResourceObject(aName: String; aObj: TJSONObject): TJSONObject;
  210. Var
  211. S : TJSONStringType;
  212. D : TJSONData;
  213. begin
  214. S:=CreateResourceRaw(aName,aObj);
  215. try
  216. D:=GetJSON(S);
  217. Result:=D as TJSONObject;
  218. except
  219. on E : Exception do
  220. begin
  221. D.Free;
  222. E.Message:='Invalid JSON returned by Create of '+aName+': '+E.Message;
  223. Raise;
  224. end;
  225. end;
  226. end;
  227. function TGitLabClient.CreateResource(aName: String; aObj: TJSONObject): Int64;
  228. Var
  229. Obj : TJSONObject;
  230. begin
  231. Obj:=CreateResourceObject(aName,aObj);
  232. try
  233. Result:=Obj.Get('id',Int64(-1));
  234. finally
  235. Obj.Free;
  236. end;
  237. end;
  238. function TGitLabClient.UpdateResourceRaw(aName: String; aObj: TJSONObject
  239. ): TJSONStringType;
  240. Var
  241. Src,Dest : TStringStream;
  242. begin
  243. Dest:=nil;
  244. Src:=TStringStream.Create(aObj.asJSON);
  245. try
  246. Dest:=TStringStream.Create('');
  247. UpdateResource(aName,Src,Dest,'application/json');
  248. Result:=Dest.DataString;
  249. finally
  250. Src.Free;
  251. Dest.Free;
  252. end;
  253. end;
  254. function TGitLabClient.UpdateResource(aName: String; aObj: TJSONObject): Int64;
  255. Var
  256. Obj : TJSONObject;
  257. begin
  258. Obj:=UpdateResourceObject(aName,aObj);
  259. try
  260. Result:=Obj.Get('id',Int64(-1));
  261. finally
  262. Obj.Free;
  263. end;
  264. end;
  265. function TGitLabClient.UpdateResourceObject(aName: String; aObj: TJSONObject
  266. ): TJSONObject;
  267. Var
  268. S : TJSONStringType;
  269. D : TJSONData;
  270. begin
  271. S:=UpdateResourceRaw(aName,aObj);
  272. try
  273. D:=GetJSON(S);
  274. Result:=D as TJSONObject;
  275. except
  276. on E : Exception do
  277. begin
  278. D.Free;
  279. E.Message:='Invalid JSON returned by Create of '+aName+': '+E.Message;
  280. Raise;
  281. end;
  282. end;
  283. end;
  284. function TGitLabClient.GetSingleResource(aName: String; aParams: array of string
  285. ): TJSONObject;
  286. Var
  287. S : TStream;
  288. D : TJSONData;
  289. begin
  290. D:=NIl;
  291. S:=TMemoryStream.Create;
  292. try
  293. GetResource(aName,aParams,S);
  294. try
  295. if S.Size>0 then
  296. D:=GetJSON(S);
  297. if (D<>Nil) and Not (D is TJSONObject) then
  298. Raise EGitlab.Create('Not a JSON object '+D.AsJSON);
  299. Result:=D as TJSONObject;
  300. except
  301. On E :Exception do
  302. E.Message:='Error getting resource'+aName+': '+E.Message;
  303. end;
  304. finally
  305. S.Free;
  306. end;
  307. end;
  308. function TGitLabClient.GetResourceList(aName: String; aParams: array of string
  309. ): TJSONArray;
  310. Var
  311. S : TStream;
  312. D : TJSONData;
  313. begin
  314. D:=NIl;
  315. S:=TMemoryStream.Create;
  316. try
  317. GetResource(aName,aParams,S);
  318. try
  319. D:=GetJSON(S);
  320. if Not (D is TJSONArray) then
  321. Raise EGitlab.Create('Not a JSON array '+D.AsJSON);
  322. Result:=D as TJSONArray;
  323. except
  324. On E :Exception do
  325. begin
  326. E.Message:='Error getting resource'+aName+': '+E.Message;
  327. Raise;
  328. end;
  329. end;
  330. finally
  331. S.Free;
  332. end;
  333. end;
  334. procedure TGitLabClient.setconfig(AValue: TGitlabConfig);
  335. begin
  336. FConfig:=AValue;
  337. end;
  338. procedure TGitLabClient.DoLog(const aMessage: string);
  339. begin
  340. If Assigned(FOnLog) then
  341. FOnLog(Self,aMessage);
  342. end;
  343. procedure TGitLabClient.DoLog(const aFmt: string; aArgs: array of const);
  344. begin
  345. DoLog(Format(aFmt,aArgs));
  346. end;
  347. function TGitLabClient.CreateRequest(aResult: TStream): TWebClientRequest;
  348. begin
  349. Result:=FClient.CreateRequest;
  350. Result.Headers.Values['Authorization']:='Bearer '+FConfig.APIkey;
  351. Result.ResponseContent:=aResult;
  352. end;
  353. function TGitLabClient.CreateURL(aName: string; aParams: array of string; useSUDO : Boolean = False): String;
  354. Var
  355. I : Integer;
  356. begin
  357. Result:=IncludeHTTPPathDelimiter(FConfig.BaseURL);
  358. Result:=Result+aName;
  359. if (Length(aParams) mod 2<>0) then
  360. Raise EGitLab.Create('URL Parameters must come in key=value pairs');
  361. I:=0;
  362. While I<Length(aParams)-1 do
  363. begin
  364. if I=0 then
  365. Result:=Result+'?'
  366. else
  367. Result:=Result+'&';
  368. Result:=Result+HTTPEncode(aParams[i])+'='+HTTPEncode(aParams[i+1]);
  369. inc(I,2);
  370. end;
  371. if UseSUDO and (Sudo<>'') then
  372. begin
  373. if Length(aParams)=0 then
  374. Result:=Result+'?'
  375. else
  376. Result:=Result+'&';
  377. Result:=Result+'sudo='+HTTPEncode(SUDO);
  378. end;
  379. end;
  380. procedure TGitLabClient.CreateResource(aName: String; aSrc: TStream;
  381. ADest: TStream; aContentType: String);
  382. begin
  383. DoResourceRequest('POST',aName,aSrc,aDest,aContentType);
  384. end;
  385. procedure TGitLabClient.DoResourceRequest(aVerb,aName: String; aSrc: TStream;
  386. ADest: TStream; aContentType: String);
  387. Function StreamToContent(S : TStream) : string;
  388. begin
  389. Result:='';
  390. if (S<>Nil) then
  391. With TStringStream.Create('') do
  392. try
  393. CopyFrom(S,0);
  394. Result:=DataString;
  395. S.Position:=0;
  396. finally
  397. Free;
  398. end;
  399. end;
  400. Var
  401. aRequest : TWebClientRequest;
  402. aResponse : TWebClientResponse;
  403. aContent,aMsg,aURL : String;
  404. aSleepTime : Integer;
  405. aSleepCount : integer;
  406. aTryCount : Integer;
  407. UseSUDO, ExitLoop : Boolean;
  408. begin
  409. aSleepCount:=1;
  410. aTryCount:=0;
  411. aResponse:=Nil;
  412. aRequest:=CreateRequest(aDest);
  413. try
  414. if (aSrc<>Nil) then
  415. begin
  416. if (aContentType='') then
  417. aContentType:='application/json';
  418. aRequest.Headers.Values['Content-Type']:=aContentType;
  419. aRequest.Content.CopyFrom(aSrc,0);
  420. end;
  421. repeat
  422. inc(aTryCount);
  423. ExitLoop:=True;
  424. UseSUDO:=False; // (Sudo<>'') and Not SameText(aVerb,'GET')
  425. aURL:=CreateURL(aName,[],UseSUDO);
  426. DoLog('URL : %s %s',[aVerb,aURL]);
  427. // Reset for loop
  428. FreeAndNil(aResponse);
  429. if Assigned(aSrc) then
  430. aRequest.Content.Position:=0;
  431. // Go !
  432. aResponse:=FClient.ExecuteRequest(aVerb,aURL,aRequest);
  433. // Throttle hit ?
  434. if aResponse.StatusCode=429 then
  435. begin
  436. aSleepTime:=LongThrottleSleep*aSleepCount;
  437. DoLog('API Throttle limit reached. Waiting %d seconds',[aSleepTime div 1000]);
  438. sleep(aSleepTime);
  439. Inc(aSleepCount);
  440. ExitLoop:=(aSleepCount>MaxSleepCount);
  441. end
  442. else if aResponse.StatusCode=409 then
  443. begin
  444. if aTryCount>1 then
  445. DoLog('Duplicate ID found at try %d, ignoring.',[aTryCount])
  446. else
  447. DoLog('Duplicate ID found at first try, ignoring anyway.');
  448. ExitLoop:=True;
  449. end
  450. else if aResponse.StatusCode=500 then
  451. begin
  452. aSleepTime:=LongThrottleSleep*aSleepCount;
  453. DoLog('Retry 500 error. Waiting %d seconds',[aSleepTime div 1000]);
  454. sleep(aSleepTime);
  455. Inc(aSleepCount);
  456. ExitLoop:=(aSleepCount>MaxSleepCount);
  457. end
  458. else if (UseSUDO and ((aResponse.StatusCode=403) or (aResponse.StatusCode=404))) then
  459. begin
  460. DoLog('SUDO request for %s failed, switching to non-sudo request',[Sudo]);
  461. ExitLoop:=False;
  462. Sudo:='';
  463. end;
  464. until ExitLoop;
  465. if (aResponse.StatusCode div 100)<>2 then
  466. begin
  467. aContent:=StreamToContent(aSrc);
  468. aMsg:=StreamToContent(aDest);
  469. Raise EGitLab.CreateFmt('Failed to %s URL "%s" : %d (%s):'+sLineBreak+'%s'+sLineBreak+'Request Content:'+sLineBreak+'%s',[aVerb,aURL,aResponse.StatusCode,aResponse.StatusText,aMsg,aContent]);
  470. end
  471. else
  472. begin
  473. if aSleepCount > 1 then
  474. DoLog('Success after %d retries', [aSleepCount-1]);
  475. end;
  476. if assigned(aDest) then
  477. aDest.Position:=0;
  478. finally
  479. aRequest.Free;
  480. aResponse.Free;
  481. end;
  482. end;
  483. procedure TGitLabClient.UpdateResource(aName: String; aSrc: TStream;
  484. ADest: TStream; aContentType: String);
  485. begin
  486. DoResourceRequest('PUT',aName,aSrc,aDest,aContentType);
  487. end;
  488. procedure TGitLabClient.GetResource(aName: String; aParams: array of string;
  489. aResult: TStream);
  490. Var
  491. aRequest : TWebClientRequest;
  492. aResponse : TWebClientResponse;
  493. aURL : String;
  494. begin
  495. aURL:=CreateURL(aName,aParams);
  496. aResponse:=Nil;
  497. aRequest:=CreateRequest(aResult);
  498. try
  499. aResponse:=FClient.ExecuteRequest('GET',aURL,aRequest);
  500. if (aResponse.StatusCode div 100)<>2 then
  501. Raise EGitLab.CreateFmt('Failed to get URL "%s" : %d (%s)',[aURL,aResponse.StatusCode,aResponse.StatusText]);
  502. aResult.Position:=0;
  503. finally
  504. aRequest.Free;
  505. aResponse.Free;
  506. end;
  507. end;
  508. function TGitLabClient.GetProjectResourceURL(aResource: string): String;
  509. begin
  510. Result:=GetResourceURL(Format('projects/%d/%s/',[FConfig.ProjectID,aResource]))
  511. end;
  512. function TGitLabClient.GetResourceURL(aResource: string): String;
  513. begin
  514. Result:= IncludeHTTPPathDelimiter(FConfig.BaseURL)+aResource;
  515. end;
  516. function TGitLabClient.ForEachResource(aResource: String; aParams: array of string;
  517. CallBack: TResourceCallback): Integer;
  518. Var
  519. Resources : TJSONArray;
  520. aLen,aTotalCount,i,aCount,aPage : Integer;
  521. aID : Int64;
  522. baseURL : String;
  523. tParams : Array of string;
  524. aContinue : Boolean;
  525. begin
  526. setLength(tParams,Length(aParams)+4);
  527. aLen:=Length(aParams);
  528. For I:=0 to Length(aParams)-1 do
  529. tParams[i]:=aParams[I];
  530. tParams[aLen]:='per_page';
  531. tParams[aLen+1]:='100';
  532. Result:=0;
  533. aPage:=1;
  534. Repeat
  535. tParams[aLen+2]:='page';
  536. tParams[aLen+3]:=IntToStr(aPage);
  537. Resources:=GetResourceList(aResource,tParams);
  538. try
  539. aCount:=Resources.Count;
  540. aContinue:=True;
  541. I:=0;
  542. While aContinue and (I<aCount) do
  543. begin
  544. CallBack(Self,aPage,I,aCount,Resources.Objects[i],aContinue);
  545. Inc(I);
  546. Inc(Result);
  547. end;
  548. finally
  549. Resources.Free;
  550. end;
  551. inc(aPage);
  552. until (aCount<100) or Not aContinue;
  553. end;
  554. procedure TGitLabClient.DeleteResource(aName: String);
  555. begin
  556. DoResourceRequest('DELETE',aName,Nil,Nil,'');
  557. end;
  558. end.