diff options
Diffstat (limited to 'lisp/forgejo-api.el')
| -rw-r--r-- | lisp/forgejo-api.el | 320 |
1 files changed, 257 insertions, 63 deletions
diff --git a/lisp/forgejo-api.el b/lisp/forgejo-api.el index 2618a14..8c29081 100644 --- a/lisp/forgejo-api.el +++ b/lisp/forgejo-api.el @@ -36,6 +36,22 @@ (declare-function forgejo-token "forgejo.el" (host-url)) (defvar forgejo--api-default-limit) +(defcustom forgejo-api-timeout 30 + "Seconds before an async API request times out. +When nil, no timeout is enforced." + :type '(choice (integer :tag "Seconds") + (const :tag "No timeout" nil)) + :group 'forgejo) + +(defcustom forgejo-api-rate-limit-warn-threshold 10 + "Warn when fewer than this many API requests remain." + :type 'integer + :group 'forgejo) + +(defvar forgejo-api--rate-limit-state (make-hash-table :test 'equal) + "Per-host rate limit state. +Keys are host URLs, values are plists with :remaining, :limit, :reset.") + ;;; URL building (defun forgejo-api--url (host endpoint &optional params) @@ -57,21 +73,37 @@ PARAMS is an alist of (KEY . VALUE) pairs for the query string." ;;; Response parsing (defun forgejo-api--parse-headers (buffer) - "Parse pagination headers from HTTP response BUFFER. -Returns a plist with :total-count and :link." + "Parse pagination and rate-limit headers from HTTP response BUFFER. +Returns a plist with :total-count, :link, :rate-limit-remaining, +:rate-limit-limit, and :rate-limit-reset." (with-current-buffer buffer (save-excursion (goto-char (point-min)) - (let (total-count link) - (while (re-search-forward "^\\([^:]+\\): \\(.+\\)\r?$" nil t) + (forward-line 1) + (let (total-count link rl-remaining rl-limit rl-reset etag last-modified) + (while (re-search-forward "^\\([^:\n]+\\): \\(.+\\)\r?$" nil t) (let ((name (downcase (match-string 1))) - (value (match-string 2))) + (value (string-trim-right (match-string 2)))) (cond ((string= name "x-total-count") (setq total-count (string-to-number value))) ((string= name "link") - (setq link value))))) - (list :total-count total-count :link link))))) + (setq link value)) + ((string= name "x-ratelimit-remaining") + (setq rl-remaining (string-to-number value))) + ((string= name "x-ratelimit-limit") + (setq rl-limit (string-to-number value))) + ((string= name "x-ratelimit-reset") + (setq rl-reset (string-to-number value))) + ((string= name "etag") + (setq etag value)) + ((string= name "last-modified") + (setq last-modified value))))) + (list :total-count total-count :link link + :rate-limit-remaining rl-remaining + :rate-limit-limit rl-limit + :rate-limit-reset rl-reset + :etag etag :last-modified last-modified))))) (defun forgejo-api--parse-response (buffer) "Parse the JSON body from HTTP response BUFFER. @@ -89,10 +121,51 @@ Returns the parsed JSON as alists/lists, or nil for empty bodies." (when (re-search-forward "^HTTP/[0-9.]+ \\([0-9]+\\)" nil t) (string-to-number (match-string 1))))) +;;; Rate limit helpers + +(defun forgejo-api--format-reset-time (unix-timestamp) + "Format UNIX-TIMESTAMP as a human-readable relative time string." + (if unix-timestamp + (let ((delta (- unix-timestamp (float-time)))) + (if (> delta 0) + (format "in %d min" (ceiling (/ delta 60))) + "now")) + "unknown")) + +(defun forgejo-api--check-rate-limit (host headers) + "Update rate limit state for HOST from HEADERS, warn if low." + (let ((remaining (plist-get headers :rate-limit-remaining))) + (when remaining + (puthash host + (list :remaining remaining + :limit (plist-get headers :rate-limit-limit) + :reset (plist-get headers :rate-limit-reset)) + forgejo-api--rate-limit-state) + (when (< remaining forgejo-api-rate-limit-warn-threshold) + (message "Forgejo API: %d requests remaining (resets %s)" + remaining + (forgejo-api--format-reset-time + (plist-get headers :rate-limit-reset))))))) + +(defun forgejo-api-rate-limit (host) + "Return the cached rate-limit plist for HOST, or nil." + (gethash host forgejo-api--rate-limit-state)) + +;;; Error helpers + +(defun forgejo-api--error-plist (http-status method endpoint message data) + "Build a structured error plist from components. +HTTP-STATUS is the numeric code (or nil for network errors). +METHOD and ENDPOINT identify the request. +MESSAGE is a human-readable error string. +DATA is the parsed response body, if any." + (list :status http-status :method method :endpoint endpoint + :message message :data data)) + ;;; Core request (defun forgejo-api--request (host method endpoint &optional params - json-body callback) + json-body callback &rest args) "Make an async HTTP request to the Forgejo API. HOST is the instance base URL (e.g. \"https://codeberg.org\"). @@ -102,53 +175,121 @@ PARAMS is an alist of query parameters. JSON-BODY, when non-nil, is an alist to encode as the request body. CALLBACK is called with two arguments: (RESPONSE-DATA HEADERS-PLIST). RESPONSE-DATA is the parsed JSON. - HEADERS-PLIST contains :total-count and :link." - (let ((url-request-method method) - (url-request-extra-headers - `(("Authorization" . ,(encode-coding-string - (concat "token " (forgejo-token host)) - 'ascii)) - ("Accept" . "application/json") - ,@(when json-body - '(("Content-Type" . "application/json"))))) - (url-request-data - (when json-body - (encode-coding-string (json-encode json-body) 'utf-8))) - (url (forgejo-api--url host endpoint params))) - (url-retrieve - url - (lambda (status) - (unwind-protect - (let ((http-status (forgejo-api--response-status (current-buffer)))) - (cond - ((and http-status (>= http-status 400)) - (let* ((err-data (condition-case nil - (forgejo-api--parse-response (current-buffer)) - (json-parse-error nil))) - (err-msg (when (listp err-data) - (alist-get 'message err-data)))) - (message "Forgejo API HTTP %d: %s %s%s" - http-status method endpoint - (if err-msg (concat " - " err-msg) "")))) - ((plist-get status :error) - (message "Forgejo API error: %S" (plist-get status :error))) - (t - (when callback - (let ((headers (forgejo-api--parse-headers (current-buffer))) - (data (forgejo-api--parse-response (current-buffer)))) - (funcall callback data headers)))))) - (kill-buffer (current-buffer)))) - nil t))) + HEADERS-PLIST contains :total-count and :link. + +ARGS is a plist of keyword options: + :error-callback -- function called with an error plist on failure. + The plist contains :status, :method, :endpoint, :message, :data. + When omitted, errors are logged to *Messages*. + :if-none-match -- ETag value for conditional requests. + :if-modified-since -- date string for conditional requests. + On 304 Not Modified, CALLBACK receives nil data." + (let* ((error-callback (plist-get args :error-callback)) + (if-none-match (plist-get args :if-none-match)) + (if-modified-since (plist-get args :if-modified-since)) + (completed (cons nil nil)) + (timeout-timer nil) + (url-request-method method) + (url-request-extra-headers + `(("Authorization" . ,(encode-coding-string + (concat "token " (forgejo-token host)) + 'ascii)) + ("Accept" . "application/json") + ,@(when json-body + '(("Content-Type" . "application/json"))) + ,@(when if-none-match + `(("If-None-Match" . ,if-none-match))) + ,@(when if-modified-since + `(("If-Modified-Since" . ,if-modified-since))))) + (url-request-data + (when json-body + (encode-coding-string (json-encode json-body) 'utf-8))) + (url (forgejo-api--url host endpoint params)) + (url-buf + (url-retrieve + url + (lambda (status) + (setcar completed t) + (when timeout-timer (cancel-timer timeout-timer)) + (unwind-protect + (let ((http-status (forgejo-api--response-status + (current-buffer)))) + (cond + ((and http-status (>= http-status 400)) + (forgejo-api--check-rate-limit + host (forgejo-api--parse-headers (current-buffer))) + (let* ((err-data (condition-case nil + (forgejo-api--parse-response + (current-buffer)) + (json-parse-error nil))) + (err-msg (when (listp err-data) + (alist-get 'message err-data))) + (error-info (forgejo-api--error-plist + http-status method endpoint + err-msg err-data))) + (if error-callback + (funcall error-callback error-info) + (message "Forgejo API HTTP %d: %s %s%s" + http-status method endpoint + (if err-msg + (concat " - " err-msg) ""))))) + ((plist-get status :error) + (let ((error-info + (forgejo-api--error-plist + nil method endpoint + (format "%S" (plist-get status :error)) + nil))) + (if error-callback + (funcall error-callback error-info) + (message "Forgejo API error: %S" + (plist-get status :error))))) + ((and http-status (= http-status 304)) + (let ((headers (forgejo-api--parse-headers + (current-buffer)))) + (forgejo-api--check-rate-limit host headers) + (when callback + (funcall callback nil headers)))) + (t + (let ((headers (forgejo-api--parse-headers + (current-buffer))) + (data (forgejo-api--parse-response + (current-buffer)))) + (forgejo-api--check-rate-limit host headers) + (when callback + (funcall callback data headers)))))) + (kill-buffer (current-buffer)))) + nil t))) + (when (and forgejo-api-timeout url-buf) + (setq timeout-timer + (run-at-time + forgejo-api-timeout nil + (lambda () + (unless (car completed) + (setcar completed t) + (when (buffer-live-p url-buf) + (let ((proc (get-buffer-process url-buf))) + (when proc (delete-process proc))) + (kill-buffer url-buf)) + (let ((error-info (forgejo-api--error-plist + nil method endpoint + "Request timed out" nil))) + (if error-callback + (funcall error-callback error-info) + (message "Forgejo API timeout: %s %s" + method endpoint)))))))))) ;;; Public wrappers -(defun forgejo-api-get (host endpoint &optional params callback) - "GET ENDPOINT on HOST with query PARAMS, call CALLBACK with (data headers)." - (forgejo-api--request host "GET" endpoint params nil callback)) +(defun forgejo-api-get (host endpoint &optional params callback &rest args) + "GET ENDPOINT on HOST with query PARAMS, call CALLBACK with (data headers). +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "GET" endpoint params nil callback args)) (defun forgejo-api-get-all (host endpoint &optional params callback) "GET all pages from ENDPOINT on HOST, call CALLBACK with (all-data headers). Fetches pages sequentially until all results are collected. +On mid-pagination failure, calls CALLBACK with partial data and +headers tagged with :partial t. PARAMS should include a \"limit\" entry. The \"page\" param is managed automatically." (let ((limit (or (cdr (assoc "limit" params)) "30")) @@ -170,14 +311,22 @@ managed automatically." (setq page (1+ page)) (fetch-page)) (when callback - (funcall callback accum headers))))))))) + (funcall callback accum headers))))) + :error-callback + (lambda (error-info) + (when callback + (funcall callback accum + (list :total-count nil :link nil + :partial t :error error-info)))))))) (fetch-page)))) (defun forgejo-api-get-paged (host endpoint params page-callback &optional done-callback) "GET all pages from ENDPOINT on HOST, calling PAGE-CALLBACK after each. PAGE-CALLBACK receives (PAGE-DATA HEADERS PAGE-NUMBER). -DONE-CALLBACK receives (ALL-DATA HEADERS) when all pages are fetched." +DONE-CALLBACK receives (ALL-DATA HEADERS) when all pages are fetched. +On mid-pagination failure, calls DONE-CALLBACK with partial data and +headers tagged with :partial t." (let ((limit (or (cdr (assoc "limit" params)) "50")) (accum nil) (page 1)) @@ -199,24 +348,69 @@ DONE-CALLBACK receives (ALL-DATA HEADERS) when all pages are fetched." (setq page (1+ page)) (fetch-page)) (when done-callback - (funcall done-callback accum headers))))))))) + (funcall done-callback accum headers))))) + :error-callback + (lambda (error-info) + (when done-callback + (funcall done-callback accum + (list :total-count nil :link nil + :partial t :error error-info)))))))) (fetch-page)))) -(defun forgejo-api-post (host endpoint &optional params json-body callback) - "POST to ENDPOINT on HOST with PARAMS and JSON-BODY, call CALLBACK." - (forgejo-api--request host "POST" endpoint params json-body callback)) +(defun forgejo-api-post (host endpoint &optional params json-body callback + &rest args) + "POST to ENDPOINT on HOST with PARAMS and JSON-BODY, call CALLBACK. +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "POST" endpoint params json-body + callback args)) + +(defun forgejo-api-patch (host endpoint &optional json-body callback &rest args) + "PATCH ENDPOINT on HOST with JSON-BODY, call CALLBACK. +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "PATCH" endpoint nil json-body + callback args)) + +(defun forgejo-api-put (host endpoint &optional json-body callback &rest args) + "PUT ENDPOINT on HOST with JSON-BODY, call CALLBACK. +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "PUT" endpoint nil json-body + callback args)) + +(defun forgejo-api-delete (host endpoint &optional json-body callback &rest args) + "DELETE ENDPOINT on HOST with optional JSON-BODY, call CALLBACK. +ARGS accepts :error-callback for failure handling." + (apply #'forgejo-api--request host "DELETE" endpoint nil json-body + callback args)) -(defun forgejo-api-patch (host endpoint &optional json-body callback) - "PATCH ENDPOINT on HOST with JSON-BODY, call CALLBACK." - (forgejo-api--request host "PATCH" endpoint nil json-body callback)) +;;; Conditional requests -(defun forgejo-api-put (host endpoint &optional json-body callback) - "PUT ENDPOINT on HOST with JSON-BODY, call CALLBACK." - (forgejo-api--request host "PUT" endpoint nil json-body callback)) +(declare-function forgejo-db-get-cache-headers "forgejo-db.el" + (host owner repo endpoint)) +(declare-function forgejo-db-set-cache-headers "forgejo-db.el" + (host owner repo endpoint etag last-modified)) -(defun forgejo-api-delete (host endpoint &optional json-body callback) - "DELETE ENDPOINT on HOST with optional JSON-BODY, call CALLBACK." - (forgejo-api--request host "DELETE" endpoint nil json-body callback)) +(defun forgejo-api-get-conditional (host endpoint params + cache-key callback &rest args) + "GET with conditional headers from DB cache. +CACHE-KEY is a list (HOST OWNER REPO ENDPOINT-NAME) for cache lookup. +On 304, calls CALLBACK with nil data. On 200, updates cached headers. +ARGS are passed through to `forgejo-api-get'." + (let* ((cached (apply #'forgejo-db-get-cache-headers cache-key)) + (etag (plist-get cached :etag)) + (last-mod (plist-get cached :last-modified))) + (apply #'forgejo-api-get host endpoint params + (lambda (data headers) + (when data + (let ((new-etag (plist-get headers :etag)) + (new-last-mod (plist-get headers :last-modified))) + (when (or new-etag new-last-mod) + (apply #'forgejo-db-set-cache-headers + (append cache-key + (list new-etag new-last-mod)))))) + (when callback (funcall callback data headers))) + :if-none-match etag + :if-modified-since last-mod + args))) ;;; Instance settings |
