339 lines
13 KiB
EmacsLisp
339 lines
13 KiB
EmacsLisp
|
;;; sx-request.el --- requests and url manipulation -*- lexical-binding: t; -*-
|
|||
|
|
|||
|
;; Copyright (C) 2014 Sean Allred
|
|||
|
|
|||
|
;; Author: Sean Allred <code@seanallred.com>
|
|||
|
|
|||
|
;; This program is free software; you can redistribute it and/or modify
|
|||
|
;; it under the terms of the GNU General Public License as published by
|
|||
|
;; the Free Software Foundation, either version 3 of the License, or
|
|||
|
;; (at your option) any later version.
|
|||
|
|
|||
|
;; This program is distributed in the hope that it will be useful,
|
|||
|
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|||
|
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|||
|
;; GNU General Public License for more details.
|
|||
|
|
|||
|
;; You should have received a copy of the GNU General Public License
|
|||
|
;; along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||
|
|
|||
|
;;; Commentary:
|
|||
|
|
|||
|
;; API requests are handled on three separate tiers:
|
|||
|
;;
|
|||
|
;; `sx-method-call':
|
|||
|
;;
|
|||
|
;; This is the function that should be used most often, since it
|
|||
|
;; runs necessary checks (authentication) and provides basic
|
|||
|
;; processing of the result for consistency.
|
|||
|
;;
|
|||
|
;; `sx-request-make':
|
|||
|
;;
|
|||
|
;; This is the fundamental function for interacting with the API.
|
|||
|
;; It makes no provisions for 'common' usage, but it does ensure
|
|||
|
;; data is retrieved successfully or an appropriate signal is
|
|||
|
;; thrown.
|
|||
|
;;
|
|||
|
;; `url.el' and `json.el':
|
|||
|
;;
|
|||
|
;; The whole solution is built upon `url-retrieve-synchronously'
|
|||
|
;; for making the request and `json-read-from-string' for parsing
|
|||
|
;; it into a properly symbolic data structure.
|
|||
|
;;
|
|||
|
;; When at all possible, use `sx-method-call'. There are specialized
|
|||
|
;; cases for the use of `sx-request-make' outside of sx-method.el, but
|
|||
|
;; these must be well-documented inline with the code.
|
|||
|
|
|||
|
;;; Code:
|
|||
|
|
|||
|
(require 'url)
|
|||
|
(require 'json)
|
|||
|
|
|||
|
(require 'sx)
|
|||
|
(require 'sx-encoding)
|
|||
|
|
|||
|
|
|||
|
;;; Variables
|
|||
|
|
|||
|
(defconst sx-request-api-key
|
|||
|
"0TE6s1tveCpP9K5r5JNDNQ(("
|
|||
|
"When passed, this key provides a higher request quota.")
|
|||
|
|
|||
|
(defconst sx-request-api-version
|
|||
|
"2.2"
|
|||
|
"The current version of the API.")
|
|||
|
|
|||
|
(defconst sx-request-api-root
|
|||
|
(format "https://api.stackexchange.com/%s/" sx-request-api-version)
|
|||
|
"The base URL to make requests from.")
|
|||
|
|
|||
|
(defcustom sx-request-unzip-program
|
|||
|
"gunzip"
|
|||
|
"Program used to unzip the response if it is compressed.
|
|||
|
This program must accept compressed data on standard input.
|
|||
|
|
|||
|
This is only used (and necessary) if the function
|
|||
|
`zlib-decompress-region' is not defined, which is the case for
|
|||
|
Emacs versions < 24.4."
|
|||
|
:group 'sx
|
|||
|
:type 'string)
|
|||
|
|
|||
|
(defvar sx-request-remaining-api-requests
|
|||
|
nil
|
|||
|
"The number of API requests remaining.
|
|||
|
Set by `sx-request-make'.")
|
|||
|
|
|||
|
(defcustom sx-request-remaining-api-requests-message-threshold
|
|||
|
50
|
|||
|
"Lower bound for printed warnings of API usage limits.
|
|||
|
After `sx-request-remaining-api-requests' drops below this
|
|||
|
number, `sx-request-make' will begin printing out the
|
|||
|
number of requests left every time it finishes a call."
|
|||
|
:group 'sx
|
|||
|
:type 'integer)
|
|||
|
|
|||
|
(defvar sx-request-all-items-delay 0
|
|||
|
"Delay in seconds with each `sx-request-all-items' iteration.
|
|||
|
It is good to use a reasonable delay to avoid rate-limiting.")
|
|||
|
|
|||
|
|
|||
|
;;; Making Requests
|
|||
|
(defvar sx--backoff-time nil)
|
|||
|
|
|||
|
(defun sx-request--wait-while-backoff ()
|
|||
|
(when sx--backoff-time
|
|||
|
(message "Waiting for backoff time: %s" sx--backoff-time)
|
|||
|
(let ((time (cadr (current-time))))
|
|||
|
(if (> (- sx--backoff-time time) 1000)
|
|||
|
;; If backoff-time is more than 1000 seconds in the future,
|
|||
|
;; we've likely just looped around the "least significant"
|
|||
|
;; bits of `current-time'.
|
|||
|
(setq sx--backoff-time time)
|
|||
|
(when (< time sx--backoff-time)
|
|||
|
(message "Backoff detected, waiting %s seconds" (- sx--backoff-time time))
|
|||
|
(sleep-for (+ 0.3 (- sx--backoff-time time))))))))
|
|||
|
|
|||
|
(defun sx-request-all-items (method &optional args request-method
|
|||
|
stop-when)
|
|||
|
"Call METHOD with ARGS until there are no more items.
|
|||
|
STOP-WHEN is a function that takes the entire response and
|
|||
|
returns non-nil if the process should stop.
|
|||
|
|
|||
|
All other arguments are identical to `sx-request-make', but
|
|||
|
PROCESS-FUNCTION is given the default value of `identity' (rather
|
|||
|
than `sx-request-response-get-items') to allow STOP-WHEN to
|
|||
|
access the response wrapper."
|
|||
|
;; @TODO: Refactor. This is the product of a late-night jam
|
|||
|
;; session... it is not intended to be model code.
|
|||
|
(declare (indent 1))
|
|||
|
(let* ((return-value nil)
|
|||
|
(current-page 1)
|
|||
|
(stop-when (or stop-when #'sx-request-all-stop-when-no-more))
|
|||
|
(process-function #'identity)
|
|||
|
(response
|
|||
|
(sx-request-make method `((page . ,current-page) ,@args)
|
|||
|
request-method process-function)))
|
|||
|
(while (not (funcall stop-when response))
|
|||
|
(let-alist response
|
|||
|
(setq current-page (1+ current-page)
|
|||
|
return-value
|
|||
|
(nconc return-value .items)))
|
|||
|
(sleep-for sx-request-all-items-delay)
|
|||
|
(setq response
|
|||
|
(sx-request-make method `((page . ,current-page) ,@args)
|
|||
|
request-method process-function)))
|
|||
|
(nconc return-value
|
|||
|
(cdr (assoc 'items response)))))
|
|||
|
|
|||
|
;;; NOTE: Whenever this is arglist changes, `sx-request-fallback' must
|
|||
|
;;; also change.
|
|||
|
(defun sx-request-make (method &optional args request-method process-function)
|
|||
|
"Make a request to the API, executing METHOD with ARGS.
|
|||
|
You should almost certainly be using `sx-method-call' instead of
|
|||
|
this function. REQUEST-METHOD is one of `get' (default) or `post'.
|
|||
|
|
|||
|
Returns the entire response as processed by PROCESS-FUNCTION.
|
|||
|
This defaults to `sx-request-response-get-items'.
|
|||
|
|
|||
|
The full set of arguments is built with
|
|||
|
`sx-request--build-keyword-arguments', prepending
|
|||
|
`sx-request-api-key' to receive a higher quota. It will also
|
|||
|
include user's `access_token` if it is avaialble. This call is
|
|||
|
then resolved with `url-retrieve-synchronously' to a temporary
|
|||
|
buffer that it returns. The headers are then stripped using a
|
|||
|
search a blank line (\"\\n\\n\"). The main body of the response
|
|||
|
is then tested with `sx-encoding-gzipped-buffer-p' for
|
|||
|
compression. If it is compressed, `sx-request-unzip-program' is
|
|||
|
called to uncompress the response. The uncompressed respons is
|
|||
|
then read with `json-read-from-string'.
|
|||
|
|
|||
|
`sx-request-remaining-api-requests' is updated appropriately and
|
|||
|
the main content of the response is returned."
|
|||
|
(declare (indent 1))
|
|||
|
(sx-request--wait-while-backoff)
|
|||
|
(let* ((url-automatic-caching t)
|
|||
|
(url-inhibit-uncompression t)
|
|||
|
(url-request-data (sx-request--build-keyword-arguments args nil))
|
|||
|
(request-url (concat sx-request-api-root method))
|
|||
|
(url-request-method (and request-method (upcase (symbol-name request-method))))
|
|||
|
(url-request-extra-headers
|
|||
|
'(("Content-Type" . "application/x-www-form-urlencoded")))
|
|||
|
(response-buffer (url-retrieve-synchronously request-url)))
|
|||
|
(if (not response-buffer)
|
|||
|
(error "Something went wrong in `url-retrieve-synchronously'")
|
|||
|
(with-current-buffer response-buffer
|
|||
|
(let* ((data (progn
|
|||
|
;; @TODO use url-http-end-of-headers
|
|||
|
(goto-char (point-min))
|
|||
|
(if (not (search-forward "\n\n" nil t))
|
|||
|
(error "Headers missing; response corrupt")
|
|||
|
(delete-region (point-min) (point))
|
|||
|
(buffer-string))))
|
|||
|
(response-zipped-p (sx-encoding-gzipped-p data))
|
|||
|
(data
|
|||
|
;; Turn string of bytes into string of characters. See
|
|||
|
;; http://emacs.stackexchange.com/q/4100/50
|
|||
|
(decode-coding-string
|
|||
|
(if (not response-zipped-p) data
|
|||
|
(if (fboundp 'zlib-decompress-region)
|
|||
|
(zlib-decompress-region (point-min) (point-max))
|
|||
|
(shell-command-on-region
|
|||
|
(point-min) (point-max)
|
|||
|
sx-request-unzip-program nil t))
|
|||
|
(buffer-string))
|
|||
|
'utf-8 'nocopy))
|
|||
|
;; @TODO should use `condition-case' here -- set
|
|||
|
;; RESPONSE to 'corrupt or something
|
|||
|
(response (with-demoted-errors "`json' error: %S"
|
|||
|
(let ((json-false nil)
|
|||
|
(json-array-type 'list)
|
|||
|
(json-null :null))
|
|||
|
(json-read-from-string data)))))
|
|||
|
(kill-buffer response-buffer)
|
|||
|
(when (not response)
|
|||
|
(error "Invalid response to the url request: %s" data))
|
|||
|
;; If we get here, the response is a valid data structure
|
|||
|
(sx-assoc-let response
|
|||
|
(when .error_id
|
|||
|
(error "Request failed: (%s) [%i %s] %S"
|
|||
|
.method .error_id .error_name .error_message))
|
|||
|
(when .backoff
|
|||
|
(message "Backoff received %s" .backoff)
|
|||
|
(setq sx--backoff-time (+ (cadr (current-time)) .backoff)))
|
|||
|
(when (< (setq sx-request-remaining-api-requests .quota_remaining)
|
|||
|
sx-request-remaining-api-requests-message-threshold)
|
|||
|
(sx-message "%d API requests remaining"
|
|||
|
sx-request-remaining-api-requests))
|
|||
|
(funcall (or process-function #'sx-request-response-get-items)
|
|||
|
response)))))))
|
|||
|
|
|||
|
(defun sx-request-fallback (_method &optional _args _request-method _process-function)
|
|||
|
"Fallback method when authentication is not available.
|
|||
|
This is for UI generation when the associated API call would
|
|||
|
require authentication.
|
|||
|
|
|||
|
Currently returns nil."
|
|||
|
'(()))
|
|||
|
|
|||
|
|
|||
|
;;; Our own generated data
|
|||
|
(defconst sx-request--data-url-format
|
|||
|
"https://raw.githubusercontent.com/vermiculus/sx.el/data/data/%s.el"
|
|||
|
"Url of the \"data\" directory inside the SX `data' branch.")
|
|||
|
|
|||
|
(defun sx-request--read-buffer-data ()
|
|||
|
"Return the buffer contents after any url headers.
|
|||
|
Error if url headers are absent or if they indicate something
|
|||
|
went wrong."
|
|||
|
(goto-char (point-min))
|
|||
|
(unless (string-match "200" (thing-at-point 'line))
|
|||
|
(error "Page not found."))
|
|||
|
(if (not (search-forward "\n\n" nil t))
|
|||
|
(error "Headers missing; response corrupt")
|
|||
|
(prog1 (buffer-substring (point) (point-max))
|
|||
|
(kill-buffer (current-buffer)))))
|
|||
|
|
|||
|
(defun sx-request-get-url (url &optional callback)
|
|||
|
"Fetch and return data stored online at URL.
|
|||
|
If CALLBACK is nil, fetching is done synchronously and the
|
|||
|
data (buffer contents sans headers) is returned as a string.
|
|||
|
|
|||
|
Otherwise CALLBACK must be a function of a single argument. Then
|
|||
|
`url-retrieve' is called asynchronously and CALLBACK is passed
|
|||
|
the retrieved data."
|
|||
|
(let* ((url-automatic-caching t)
|
|||
|
(url-inhibit-uncompression t)
|
|||
|
(url-request-method "GET")
|
|||
|
(url-request-extra-headers
|
|||
|
'(("Content-Type" . "application/x-www-form-urlencoded")))
|
|||
|
(callback-internal
|
|||
|
(when callback
|
|||
|
;; @TODO: Error check in STATUS.
|
|||
|
(lambda (_status)
|
|||
|
(funcall callback (sx-request--read-buffer-data)))))
|
|||
|
(response-buffer
|
|||
|
(if callback (url-retrieve url callback-internal nil 'silent)
|
|||
|
(url-retrieve-synchronously url))))
|
|||
|
(unless callback
|
|||
|
(if (not response-buffer)
|
|||
|
(error "Something went wrong in `url-retrieve-synchronously'")
|
|||
|
(with-current-buffer response-buffer
|
|||
|
(sx-request--read-buffer-data))))))
|
|||
|
|
|||
|
(defun sx-request-get-data (file)
|
|||
|
"Fetch and return data stored online by SX.
|
|||
|
FILE is a string or symbol, the name of the file which holds the
|
|||
|
desired data, relative to `sx-request--data-url-format'. For
|
|||
|
instance, `tags/emacs' returns the list of tags on Emacs.SE."
|
|||
|
(read (sx-request-get-url
|
|||
|
(format sx-request--data-url-format file))))
|
|||
|
|
|||
|
|
|||
|
;;; Support Functions
|
|||
|
(defun sx-request--build-keyword-arguments (alist &optional kv-sep)
|
|||
|
"Format ALIST as a key-value list joined with KV-SEP.
|
|||
|
If authentication is needed, include it also or error if it is
|
|||
|
not available.
|
|||
|
|
|||
|
Build a \"key=value&key=value&...\"-style string with the elements
|
|||
|
of ALIST. If any value in the alist is nil, that pair will not
|
|||
|
be included in the return. If you wish to pass a notion of
|
|||
|
false, use the symbol `false'. Each element is processed with
|
|||
|
`sx--thing-as-string'."
|
|||
|
;; Add API key to list of arguments, this allows for increased quota
|
|||
|
;; automatically.
|
|||
|
(let ((api-key (cons "key" sx-request-api-key))
|
|||
|
(auth (car (sx-cache-get 'auth))))
|
|||
|
(push api-key alist)
|
|||
|
(when auth
|
|||
|
(push auth alist))
|
|||
|
(mapconcat
|
|||
|
(lambda (pair)
|
|||
|
(concat
|
|||
|
(sx--thing-as-string (car pair))
|
|||
|
"="
|
|||
|
(sx--thing-as-string (cdr pair) kv-sep t)))
|
|||
|
(delq nil (mapcar
|
|||
|
(lambda (pair)
|
|||
|
(when (cdr pair) pair))
|
|||
|
alist))
|
|||
|
"&")))
|
|||
|
|
|||
|
|
|||
|
;;; Response Processors
|
|||
|
(defun sx-request-response-get-items (response)
|
|||
|
"Returns the items from RESPONSE."
|
|||
|
(sx-assoc-let response
|
|||
|
(sx-encoding-clean-content-deep .items)))
|
|||
|
|
|||
|
(defun sx-request-all-stop-when-no-more (response)
|
|||
|
(or (not response)
|
|||
|
(not (cdr (assoc 'has_more response)))))
|
|||
|
|
|||
|
(provide 'sx-request)
|
|||
|
;;; sx-request.el ends here
|
|||
|
|
|||
|
;; Local Variables:
|
|||
|
;; indent-tabs-mode: nil
|
|||
|
;; End:
|