;;; sx-request.el --- requests and url manipulation -*- lexical-binding: t; -*- ;; Copyright (C) 2014 Sean Allred ;; Author: Sean Allred ;; 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 . ;;; 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: