;;; restclient.el --- An interactive HTTP client for Emacs ;; ;; Public domain. ;; Author: Pavel Kurnosov ;; Maintainer: Pavel Kurnosov ;; Created: 01 Apr 2012 ;; Keywords: http ;; Package-Version: 20160801.707 ;; This file is not part of GNU Emacs. ;; This file is public domain software. Do what you want. ;;; Commentary: ;; ;; This is a tool to manually explore and test HTTP REST ;; webservices. Runs queries from a plain-text query sheet, displays ;; results as a pretty-printed XML, JSON and even images. ;;; Code: ;; (require 'url) (require 'json) (defgroup restclient nil "An interactive HTTP client for Emacs." :group 'tools) (defcustom restclient-log-request t "Log restclient requests to *Messages*." :group 'restclient :type 'boolean) (defcustom restclient-same-buffer-response t "Re-use same buffer for responses or create a new one each time." :group 'restclient :type 'boolean) (defcustom restclient-same-buffer-response-name "*HTTP Response*" "Name for response buffer." :group 'restclient :type 'string) (defcustom restclient-inhibit-cookies nil "Inhibit restclient from sending cookies implicitly." :group 'restclient :type 'boolean) (defgroup restclient-faces nil "Faces used in Restclient Mode" :group 'restclient :group 'faces) (defface restclient-variable-name-face '((t (:inherit font-lock-preprocessor-face))) "Face for variable name." :group 'restclient-faces) (defface restclient-variable-string-face '((t (:inherit font-lock-string-face))) "Face for variable value (string)." :group 'restclient-faces) (defface restclient-variable-elisp-face '((t (:inherit font-lock-function-name-face))) "Face for variable value (Emacs lisp)." :group 'restclient-faces) (defface restclient-variable-multiline-face '((t (:inherit font-lock-doc-face))) "Face for multi-line variable value marker." :group 'restclient-faces) (defface restclient-variable-usage-face '((t (:inherit restclient-variable-name-face))) "Face for variable usage (only used when headers/body is represented as a single variable, not highlighted when variable appears in the middle of other text)." :group 'restclient-faces) (defface restclient-method-face '((t (:inherit font-lock-keyword-face))) "Face for HTTP method." :group 'restclient-faces) (defface restclient-url-face '((t (:inherit font-lock-function-name-face))) "Face for variable value (Emacs lisp)." :group 'restclient-faces) (defface restclient-file-upload-face '((t (:inherit restclient-variable-multiline-face))) "Face for highlighting upload file paths." :group 'restclient-faces) (defface restclient-header-name-face '((t (:inherit font-lock-variable-name-face))) "Face for HTTP header name." :group 'restclient-faces) (defface restclient-header-value-face '((t (:inherit font-lock-string-face))) "Face for HTTP header value." :group 'restclient-faces) (defvar restclient-within-call nil) (defvar restclient-request-time-start nil) (defvar restclient-request-time-end nil) (defvar restclient-response-loaded-hook nil "Hook run after response buffer created and data loaded.") (defvar restclient-http-do-hook nil "Hook to run before making request.") (defcustom restclient-vars-max-passes 10 "Maximum number of recursive variable references. This is to prevent hanging if two variables reference each other directly or indirectly." :group 'restclient :type 'integer) (defconst restclient-comment-separator "#") (defconst restclient-comment-start-regexp (concat "^" restclient-comment-separator)) (defconst restclient-comment-not-regexp (concat "^[^" restclient-comment-separator "]")) (defconst restclient-empty-line-regexp "^\\s-*$") (defconst restclient-method-url-regexp "^\\(GET\\|POST\\|DELETE\\|PUT\\|HEAD\\|OPTIONS\\|PATCH\\) \\(.*\\)$") (defconst restclient-header-regexp "^\\([^](),/:;@[\\{}= \t]+\\): \\(.*\\)$") (defconst restclient-use-var-regexp "^\\(:[^: \n]+\\)$") (defconst restclient-var-regexp (concat "^\\(:[^: ]+\\)[ \t]*\\(:?\\)=[ \t]*\\(<<[ \t]*\n\\(\\(.*\n\\)*?\\)" restclient-comment-separator "\\|\\([^<].*\\)$\\)")) (defconst restclient-svar-regexp "^\\(:[^: ]+\\)[ \t]*=[ \t]*\\(.+?\\)$") (defconst restclient-evar-regexp "^\\(:[^: ]+\\)[ \t]*:=[ \t]*\\(.+?\\)$") (defconst restclient-mvar-regexp "^\\(:[^: ]+\\)[ \t]*:?=[ \t]*\\(<<\\)[ \t]*$") (defconst restclient-file-regexp "^<[ \t]*\\([^<>]+\\)[ \t]*$") (defconst restclient-content-type-regexp "^Content-[Tt]ype: \\(\\w+\\)/\\(?:[^\\+\r\n]*\\+\\)*\\([^;\r\n]+\\)") ;; The following disables the interactive request for user name and ;; password should an API call encounter a permission-denied response. ;; This API is meant to be usable without constant asking for username ;; and password. (defadvice url-http-handle-authentication (around restclient-fix) (if restclient-within-call (setq ad-return-value t) ad-do-it)) (ad-activate 'url-http-handle-authentication) (defadvice url-cache-extract (around restclient-fix-2) (unless restclient-within-call ad-do-it)) (ad-activate 'url-cache-extract) (defadvice url-http-user-agent-string (around restclient-fix-3) (if restclient-within-call (setq ad-return-value nil) ad-do-it)) (ad-activate 'url-http-user-agent-string) (defun restclient-restore-header-variables () (url-set-mime-charset-string) (setq url-mime-language-string nil) (setq url-mime-encoding-string nil) (setq url-mime-accept-string nil) (setq url-personal-mail-address nil)) (defun restclient-http-do (method url headers entity &rest handle-args) "Send ENTITY and HEADERS to URL as a METHOD request." (if restclient-log-request (message "HTTP %s %s Headers:[%s] Body:[%s]" method url headers entity)) (let ((url-request-method method) (url-request-extra-headers '()) (url-request-data (encode-coding-string entity 'utf-8))) (restclient-restore-header-variables) (dolist (header headers) (let* ((mapped (assoc-string (downcase (car header)) '(("from" . url-personal-mail-address) ("accept-encoding" . url-mime-encoding-string) ("accept-charset" . url-mime-charset-string) ("accept-language" . url-mime-language-string) ("accept" . url-mime-accept-string))))) (if mapped (set (cdr mapped) (cdr header)) (setq url-request-extra-headers (cons header url-request-extra-headers))) )) (setq restclient-within-call t) (setq restclient-request-time-start (current-time)) (run-hooks 'restclient-http-do-hook) (url-retrieve url 'restclient-http-handle-response (append (list method url (if restclient-same-buffer-response restclient-same-buffer-response-name (format "*HTTP %s %s*" method url))) handle-args) nil restclient-inhibit-cookies))) (defun restclient-prettify-response (method url) (save-excursion (let ((start (point)) (guessed-mode) (end-of-headers)) (while (and (not (looking-at restclient-empty-line-regexp)) (eq (progn (when (looking-at restclient-content-type-regexp) (setq guessed-mode (cdr (assoc-string (concat (match-string-no-properties 1) "/" (match-string-no-properties 2)) '(("text/xml" . xml-mode) ("application/xml" . xml-mode) ("application/json" . js-mode) ("image/png" . image-mode) ("image/jpeg" . image-mode) ("image/jpg" . image-mode) ("image/gif" . image-mode) ("text/html" . html-mode)))))) (forward-line)) 0))) (setq end-of-headers (point)) (while (and (looking-at restclient-empty-line-regexp) (eq (forward-line) 0))) (unless guessed-mode (setq guessed-mode (or (assoc-default nil ;; magic mode matches '(("<\\?xml " . xml-mode) ("{\\s-*\"" . js-mode)) (lambda (re _dummy) (looking-at re))) 'js-mode))) (let ((headers (buffer-substring-no-properties start end-of-headers))) (when guessed-mode (delete-region start (point)) (unless (eq guessed-mode 'image-mode) (apply guessed-mode '()) (if (fboundp 'font-lock-flush) (font-lock-flush) (with-no-warnings (font-lock-fontify-buffer)))) (cond ((eq guessed-mode 'xml-mode) (goto-char (point-min)) (while (search-forward-regexp "\>[ \\t]*\<" nil t) (backward-char) (insert "\n")) (indent-region (point-min) (point-max))) ((eq guessed-mode 'image-mode) (let* ((img (buffer-string))) (delete-region (point-min) (point-max)) (fundamental-mode) (insert-image (create-image img nil t)))) ((eq guessed-mode 'js-mode) (let ((json-special-chars (remq (assoc ?/ json-special-chars) json-special-chars))) (ignore-errors (json-pretty-print-buffer))) (restclient-prettify-json-unicode))) (goto-char (point-max)) (or (eq (point) (point-min)) (insert "\n")) (let ((hstart (point))) (insert method " " url "\n" headers) (insert (format "Request duration: %fs\n" (float-time (time-subtract restclient-request-time-end restclient-request-time-start)))) (unless (eq guessed-mode 'image-mode) (comment-region hstart (point)) (indent-region hstart (point))))))))) (defun restclient-prettify-json-unicode () (save-excursion (goto-char (point-min)) (while (re-search-forward "\\\\[Uu]\\([0-9a-fA-F]\\{4\\}\\)" nil t) (replace-match (char-to-string (decode-char 'ucs (string-to-number (match-string 1) 16))) t nil)))) (defun restclient-http-handle-response (status method url bufname raw stay-in-window) "Switch to the buffer returned by `url-retreive'. The buffer contains the raw HTTP response sent by the server." (setq restclient-within-call nil) (setq restclient-request-time-end (current-time)) (if (= (point-min) (point-max)) (signal (car (plist-get status :error)) (cdr (plist-get status :error))) (restclient-restore-header-variables) (when (buffer-live-p (current-buffer)) (with-current-buffer (restclient-decode-response (current-buffer) bufname restclient-same-buffer-response) (unless raw (restclient-prettify-response method url)) (buffer-enable-undo) (run-hooks 'restclient-response-loaded-hook) (if stay-in-window (display-buffer (current-buffer) t) (switch-to-buffer-other-window (current-buffer))))))) (defun restclient-decode-response (raw-http-response-buffer target-buffer-name same-name) "Decode the HTTP response using the charset (encoding) specified in the Content-Type header. If no charset is specified, default to UTF-8." (let* ((charset-regexp "Content-Type.*charset=\\([-A-Za-z0-9]+\\)") (image? (save-excursion (search-forward-regexp "Content-Type.*[Ii]mage" nil t))) (encoding (if (save-excursion (search-forward-regexp charset-regexp nil t)) (intern (downcase (match-string 1))) 'utf-8))) (if image? ;; Dont' attempt to decode. Instead, just switch to the raw HTTP response buffer and ;; rename it to target-buffer-name. (with-current-buffer raw-http-response-buffer ;; We have to kill the target buffer if it exists, or `rename-buffer' ;; will raise an error. (when (get-buffer target-buffer-name) (kill-buffer target-buffer-name)) (rename-buffer target-buffer-name) raw-http-response-buffer) ;; Else, switch to the new, empty buffer that will contain the decoded HTTP ;; response. Set its encoding, copy the content from the unencoded ;; HTTP response buffer and decode. (let ((decoded-http-response-buffer (get-buffer-create (if same-name target-buffer-name (generate-new-buffer-name target-buffer-name))))) (with-current-buffer decoded-http-response-buffer (setq buffer-file-coding-system encoding) (save-excursion (erase-buffer) (insert-buffer-substring raw-http-response-buffer)) (kill-buffer raw-http-response-buffer) (condition-case nil (decode-coding-region (point-min) (point-max) encoding) (error (message (concat "Error when trying to decode http response with encoding: " (symbol-name encoding))))) decoded-http-response-buffer))))) (defun restclient-current-min () (save-excursion (beginning-of-line) (if (looking-at restclient-comment-start-regexp) (if (re-search-forward restclient-comment-not-regexp (point-max) t) (point-at-bol) (point-max)) (if (re-search-backward restclient-comment-start-regexp (point-min) t) (point-at-bol 2) (point-min))))) (defun restclient-current-max () (save-excursion (if (re-search-forward restclient-comment-start-regexp (point-max) t) (max (- (point-at-bol) 1) 1) (progn (goto-char (point-max)) (if (looking-at "^$") (- (point) 1) (point)))))) (defun restclient-replace-all-in-string (replacements string) (if replacements (let ((current string) (pass restclient-vars-max-passes) (continue t)) (while (and continue (> pass 0)) (setq pass (- pass 1)) (setq current (replace-regexp-in-string (regexp-opt (mapcar 'car replacements)) (lambda (key) (setq continue t) (cdr (assoc key replacements))) current t t))) current) string)) (defun restclient-replace-all-in-header (replacements header) (cons (car header) (restclient-replace-all-in-string replacements (cdr header)))) (defun restclient-chop (text) (if text (replace-regexp-in-string "\n$" "" text) nil)) (defun restclient-find-vars-before-point () (let ((vars nil) (bound (point))) (save-excursion (goto-char (point-min)) (while (search-forward-regexp restclient-var-regexp bound t) (let ((name (match-string-no-properties 1)) (should-eval (> (length (match-string 2)) 0)) (value (or (restclient-chop (match-string-no-properties 4)) (match-string-no-properties 3)))) (setq vars (cons (cons name (if should-eval (restclient-eval-var value) value)) vars)))) vars))) (defun restclient-eval-var (string) (with-output-to-string (princ (eval (read string))))) (defun restclient-make-header (&optional string) (cons (match-string-no-properties 1 string) (match-string-no-properties 2 string))) (defun restclient-parse-headers (string) (let ((start 0) (headers '())) (while (string-match restclient-header-regexp string start) (setq headers (cons (restclient-make-header string) headers) start (match-end 0))) headers)) (defun restclient-read-file (path) (with-temp-buffer (insert-file-contents path) (buffer-string))) (defun restclient-parse-body (entity vars) (if (= 0 (or (string-match restclient-file-regexp entity) 1)) (restclient-read-file (match-string 1 entity)) (restclient-replace-all-in-string vars entity))) (defun restclient-http-parse-current-and-do (func &rest args) (save-excursion (goto-char (restclient-current-min)) (when (re-search-forward restclient-method-url-regexp (point-max) t) (let ((method (match-string-no-properties 1)) (url (match-string-no-properties 2)) (vars (restclient-find-vars-before-point)) (headers '())) (forward-line) (while (cond ((and (looking-at restclient-header-regexp) (not (looking-at restclient-empty-line-regexp))) (setq headers (cons (restclient-replace-all-in-header vars (restclient-make-header)) headers))) ((looking-at restclient-use-var-regexp) (setq headers (append headers (restclient-parse-headers (restclient-replace-all-in-string vars (match-string 1))))))) (forward-line)) (when (looking-at restclient-empty-line-regexp) (forward-line)) (let* ((cmax (restclient-current-max)) (entity (restclient-parse-body (buffer-substring (min (point) cmax) cmax) vars)) (url (restclient-replace-all-in-string vars url))) (apply func method url headers entity args)))))) (defun restclient-copy-curl-command () "Formats the request as a curl command and copies the command to the clipboard." (interactive) (restclient-http-parse-current-and-do '(lambda (method url headers entity) (kill-new (format "curl -i %s -X%s '%s' %s" (mapconcat (lambda (header) (format "-H '%s: %s'" (car header) (cdr header))) headers " ") method url (if (> (string-width entity) 0) (format "-d '%s'" entity) ""))) (message "curl command copied to clipboard.")))) ;;;###autoload (defun restclient-http-send-current (&optional raw stay-in-window) "Sends current request. Optional argument RAW don't reformat response if t. Optional argument STAY-IN-WINDOW do not move focus to response buffer if t." (interactive) (restclient-http-parse-current-and-do 'restclient-http-do raw stay-in-window)) ;;;###autoload (defun restclient-http-send-current-raw () "Sends current request and get raw result (no reformatting or syntax highlight of XML, JSON or images)." (interactive) (restclient-http-send-current t)) ;;;###autoload (defun restclient-http-send-current-stay-in-window () "Send current request and keep focus in request window." (interactive) (restclient-http-send-current nil t)) (defun restclient-jump-next () "Jump to next request in buffer." (interactive) (let ((last-min nil)) (while (not (eq last-min (goto-char (restclient-current-min)))) (goto-char (restclient-current-min)) (setq last-min (point)))) (goto-char (+ (restclient-current-max) 1)) (goto-char (restclient-current-min))) (defun restclient-jump-prev () "Jump to previous request in buffer." (interactive) (let* ((current-min (restclient-current-min)) (end-of-entity (save-excursion (progn (goto-char (restclient-current-min)) (while (and (or (looking-at "^\s*\\(#.*\\)?$") (eq (point) current-min)) (not (eq (point) (point-min)))) (forward-line -1) (beginning-of-line)) (point))))) (unless (eq (point-min) end-of-entity) (goto-char end-of-entity) (goto-char (restclient-current-min))))) (defun restclient-mark-current () "Mark current request." (interactive) (goto-char (restclient-current-min)) (set-mark-command nil) (goto-char (restclient-current-max)) (backward-char 1) (setq deactivate-mark nil)) (defun restclient-narrow-to-current () "Narrow to region of current request" (interactive) (narrow-to-region (restclient-current-min) (restclient-current-max))) (defconst restclient-mode-keywords (list (list restclient-method-url-regexp '(1 'restclient-method-face) '(2 'restclient-url-face)) (list restclient-svar-regexp '(1 'restclient-variable-name-face) '(2 'restclient-variable-string-face)) (list restclient-evar-regexp '(1 'restclient-variable-name-face) '(2 'restclient-variable-elisp-face t)) (list restclient-mvar-regexp '(1 'restclient-variable-name-face) '(2 'restclient-variable-multiline-face t)) (list restclient-use-var-regexp '(1 'restclient-variable-usage-face)) (list restclient-file-regexp '(0 'restclient-file-upload-face)) (list restclient-header-regexp '(1 'restclient-header-name-face t) '(2 'restclient-header-value-face t)) )) (defconst restclient-mode-syntax-table (let ((table (make-syntax-table))) (modify-syntax-entry ?\# "<" table) (modify-syntax-entry ?\n ">#" table) table)) ;;;###autoload (define-derived-mode restclient-mode fundamental-mode "REST Client" "Turn on restclient mode." (local-set-key (kbd "C-c C-c") 'restclient-http-send-current) (local-set-key (kbd "C-c C-r") 'restclient-http-send-current-raw) (local-set-key (kbd "C-c C-v") 'restclient-http-send-current-stay-in-window) (local-set-key (kbd "C-c C-n") 'restclient-jump-next) (local-set-key (kbd "C-c C-p") 'restclient-jump-prev) (local-set-key (kbd "C-c C-.") 'restclient-mark-current) (local-set-key (kbd "C-c C-u") 'restclient-copy-curl-command) (local-set-key (kbd "C-c n n") 'restclient-narrow-to-current) (set (make-local-variable 'comment-start) "# ") (set (make-local-variable 'comment-start-skip) "# *") (set (make-local-variable 'comment-column) 48) (set (make-local-variable 'font-lock-defaults) '(restclient-mode-keywords))) (provide 'restclient) (eval-after-load 'helm '(ignore-errors (require 'restclient-helm))) ;;; restclient.el ends here