gitlabclient.pas 16 KB

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