EmacsのHTTPクライアント事情について考える

Emacs にはHTTPリクエストを送信する方法はいくつかある。それぞれ目的や想定している状況が異なる。これを使えばよいというものは本来無いので、自分の頭で考えて選択していく事にする。

ダミーサーバー

HTTPクライアントを使うには、当然リクエストを送信する先のサーバーが必要になる。今回は yay と返すだけのシンプルなサーバーを Python で実装した。

from socket import socket

soc = socket()
soc.bind(("localhost", 8000))

try:
    soc.listen(1)
    while True:
        (conn, addr) = soc.accept()
        print(conn, addr)
        print(conn.recv(10000))
        conn.send(b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\n\r\nyay")
        conn.close()
finally:
    soc.close()

このサーバーを起動しておく。

python simple-http.py

SSL/TLS については一旦考えない事にする。

一般的なHTTPクライアントライブラリ

プログラミング言語にはそれぞれHTTPクライアントライブラリが、サードパーティとして実装されていたり、標準ライブラリとして組み込まれていたりする事が多い。 Emacs には標準のライブラリとサードパーティのパッケージの両方がある。

url-retrieve

Emacs でHTTPのリクエストを送信する関数の代表格は url-retrieve だろう1 。この関数は lisp/url/url.el.gz で定義されており、Emacsに標準で梱包されている。第1引数には送信先のURL、第2引数には受け取ったレスポンスを元に呼び出すコールバックのシンボルを指定する。

(require 'url)

(url-retrieve "http://localhost:8000" (lambda (r) nil))
#<buffer  *http localhost:8000*-37929>
url-retrieve を使ってHTTPリクエストを送信する。
b'GET / HTTP/1.1\r\nMIME-Version: 1.0\r\nConnection: keep-alive\r\nHost: localhost:8000\r\nAccept-encoding: gzip\r\nAccept: */*\r\nUser-Agent: URL/Emacs Emacs/30.0.50 (OpenStep; x86_64-apple-darwin21.6.0)\r\n\r\n'
サーバーが受け取ったリクエストのデータ。

HTTPメソッドを変更するには、送信したい値を url-request-method に設定し、 url-retrieve を呼び出す。

(require 'url)

(let ((url-request-method "POST" ))
  (url-retrieve "http://localhost:8000" (lambda (r) nil)))
#<buffer  *http localhost:8000*-568096>
メソッドを変更してHTTPリクエストを送信する。
b'POST / HTTP/1.1\r\nMIME-Version: 1.0\r\nConnection: keep-alive\r\nHost: localhost:8000\r\nAccept-encoding: gzip\r\nAccept: */*\r\nUser-Agent: URL/Emacs Emacs/30.0.50 (OpenStep; x86_64-apple-darwin21.6.0)\r\n\r\n'
サーバーが受け取ったリクエストのデータ。

ヘッダを変更する場合は、 url-request-extra-headers を設定する。またリクエストBODYを設定する場合は、 url-request-data を設定する。

(require 'url)

(let ((url-request-extra-headers '(("User-Agent" . "DUMMY"))))
  (url-retrieve "http://localhost:8000" (lambda (r) nil)))
#<buffer  *http localhost:8000*-889520>
リクエストヘッダを変更してHTTPリクエストを送信する。
b'GET / HTTP/1.1\r\nMIME-Version: 1.0\r\nConnection: keep-alive\r\nHost: localhost:8000\r\nAccept-encoding: gzip\r\nAccept: */*\r\nUser-Agent: URL/Emacs Emacs/30.0.50 (OpenStep; x86_64-apple-darwin21.6.0)\r\nUser-Agent: DUMMY\r\n\r\n'
サーバーが受け取ったリクエストのデータ。

リクエストBODYを設定する場合は、 url-request-data を設定する。

(require 'url)

(let ((url-request-data "DUMMY"))
  (url-retrieve "http://localhost:8000" (lambda (r) nil)))
#<buffer  *http localhost:8000*-305063>
リクエストヘッダを変更してHTTPリクエストを送信する。
b'GET / HTTP/1.1\r\nMIME-Version: 1.0\r\nConnection: keep-alive\r\nHost: localhost:8000\r\nAccept-encoding: gzip\r\nAccept: */*\r\nUser-Agent: URL/Emacs Emacs/30.0.50 (OpenStep; x86_64-apple-darwin21.6.0)\r\nContent-length: 5\r\n\r\nDUMMY'
サーバーが受け取ったリクエストのデータ。

同期的にリクエストを送信するための url-retrieve-synchronously も用意されている。

request.el

request.el 2cURL が使える環境であれば、リクエストの送信に cURL を使用する。 cURL が使えない環境では url.el に縮退して動作する。このパッケージはMELPAに登録されている。

(require 'request)

(request "http://localhost:8000/get")
  :parser 'json-read
  :success (cl-function
            (lambda (&key data &allow-other-keys)
              (message "I sent: %S" (assoc-default 'args data)))))
b'GET /get?key=value&key2=value2 HTTP/1.1\r\nHost: localhost:8000\r\nUser-Agent: curl/8.1.2\r\nAccept: */*\r\nAccept-Encoding: deflate, gzip\r\n\r\n'
サーバーが受け取ったリクエストのデータ。

plz.el

plz.elrequest.el と同様に cURL を使用可能であれば cURL を使用し、そうでなければ url.el の関数を使用する。こちらはELPAに登録されている。

(require 'plz)

(plz 'get "http://localhost:8000")
yay
b'GET / HTTP/1.1\r\nHost: localhost:8000\r\nUser-Agent: curl/8.1.2\r\nAccept: */*\r\nAccept-Encoding: deflate, gzip\r\n\r\n'
サーバーが受け取ったリクエストのデータ。

HTTPリクエストの検証用としてのHTTPクライアント

HTTP(s)で提供するWeb APIを開発する場合、そのWeb APIが正しい動作をしているか、パラメータを変更して送信するとどうなるか等、試行錯誤しながら動作を確認したい。Emacs以外で使われる競合するツールとしては、 cURLPostman が挙げられる。

http.el

HTTPの形式に似た独自の構文に従ってHTTPリクエストを記述でき、そのリクエストを送信しレスポンスを確認するといったことができる。それらが定義する通常は request.http といったようなファイルを作成し、そのファイル内にリクエストの内容を記述する。検証時やドキュメントとして使用することが多い。

GET http://localhost:8000
HTTP/1.1 200 OK
Content-Length: 3

yay
test.http
b'GET / HTTP/1.1\r\nHost: localhost:8000\r\nUser-Agent: curl/8.1.2\r\nAccept: */*\r\n\r\n'
サーバーが受け取ったリクエストのデータ。

restclient.el

restclient.elhttp.el と同様の機能を提供する。ただし細かい違いもある。変数展開などが使用できるため、大部分においては http.el の上位互換と言えなくもない。ただし、具体的な事を忘れてしまったが、 restclient.el を使っていて「ここは http.el の方が優れているな」と思った記憶がある。とても小さなピンポイントの内容だった気がするが忘れてしまった。思い出したら書く事にする。

GET http://localhost:8000
yay
// GET http://localhost:8000
// HTTP/1.1 200 OK
// Content-Length: 3
// Request duration: 0.002433s
test.restclient
b'GET / HTTP/1.1\r\nMIME-Version: 1.0\r\nConnection: keep-alive\r\nHost: localhost:8000\r\nAccept-encoding: gzip\r\nAccept: */*\r\nContent-length: 0\r\n\r\n'
サーバーが受け取ったリクエストのデータ。

httprepl.el

これは存在を知らなかった。かなり古いコードだが、今度使ってみたい。

https://github.com/gregsexton/httprepl.el

cURLを直接使う

HTTPリクエストを送信するような機能は、低レベルな機能を言語の標準ライブラリとして提供し、ユーザーやサードパーティのライブラリはそれを利用するという事が多い。

Emacsでも url-retrieve は標準ライブラリとして提供されているけれど、HTTP送信のプログラムをサブプセスとして起動し利用すれば、そのコードやラッパーを使わずリクエストを送信する事はできる。

HTTPリクエストを送信するプログラムとしてcURLはよく使われる。cURLは送信する内容をコマンドの引数として渡すが、設定をファイルから読み込む事もでるし、その設定を標準入力から渡す事もできる。

ここでは、設定を標準入力から渡しcURLを起動する事でリクエストを送信する方法を試す。 make-process 関数でサブプセスとして呼び出し、cURLの設定をバッファに生成し、そのバッファのデータをサブプロセスの標準入力に流し込む。これの嬉しい所は、cURLの設定ファイルの形式に則り、リクエストを手動で変更できる所だ。

;;; curl ---  cURL Utility -*- lexical-binding: t -*-

;; Copyright (C) 2024 TakesxiSximada

;; Author: TakesxiSximada <[email protected]>
;; Maintainer: TakesxiSximada  <[email protected]>
;; Version: 3
;; Package-Version: 20240321.0000
;; Package-Requires:
;; Date: 2024-03-21

;; This file is not part of GNU Emacs.

;;; License:

;; This program is free software: you can redistribute it and/or
;; modify it under the terms of the GNU Affero 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
;; Affero General Public License for more details.

;; You should have received a copy of the GNU Affero General Public
;; License along with this program.  If not, see
;; <https://www.gnu.org/licenses/>.

;;; Code:

(defcustom curl-executable "curl"
  "Path to curl command"
  :group 'curl
  :type 'file)

(defcustom curl-config-buffer-name "*cURL Config*"
  "Name of cURL Config Buffer"
  :group 'curl
  :type 'string)


(defcustom curl-process-buffer-name "*cURL Process*"
  "Name of cURL Process Buffer"
  :group 'curl
  :type 'string)

(defcustom curl-output-buffer-name "*cURL Output*"
  "Name of cURL Output Buffer"
  :group 'curl
  :type 'string)

(defcustom curl-error-buffer-name "*cURL Error*"
  "Name of cURL Error Buffer"
  :group 'curl
  :type 'string)

(defvar curl-config-template
  "# cURL Configuration for Emacs

url %s

request %s

header %s

data %s

")

(defvar curl-process nil)
(defvar curl-process-sentinel nil)
(defvar curl-config-buffer nil)
(defvar curl-process-sentinel-hook nil)

;;;###autoload
(defun curl-create-config ()
  (interactive)
  (with-current-buffer (get-buffer-create curl-config-buffer-name)
    (erase-buffer)
    (insert
     (format curl-config-template
	     (json-encode-string "http://example.com")
	     (json-encode-string "GET")
	     (json-encode-string "Content-Type: application/json")
	     (json-encode-string "{}")))
    (curl-config-mode))
  (display-buffer curl-config-buffer-name))

;;;###autoload
(defun curl-send-request ()
  "Launch curl as a subprocess and send requests by feeding settings into the standard input."
  (interactive)
  (setq curl-config-buffer (get-buffer curl-config-buffer-name))
  (setq curl-output-buffer (get-buffer-create curl-output-buffer-name))
  (setq curl-error-buffer (get-buffer-create curl-error-buffer-name))

  (with-current-buffer curl-output-buffer
    (erase-buffer))

  (with-current-buffer curl-error-buffer
    (erase-buffer))

  (setq curl-process
	(make-process
	 :name "curl"
	 :buffer (get-buffer-create curl-output-buffer-name)
	 :stderr (get-buffer-create curl-error-buffer-name)
	 :command `(,curl-executable "-v" "-K-")
	 :sentinel (lambda (process event)
		     (run-hook-with-args
		      'curl-process-sentinel-hook process event))))

  (process-send-string curl-process
		       (with-current-buffer curl-config-buffer
			 (buffer-string)))
  (process-send-eof curl-process)

  (setq curl-result-output-window (split-window-horizontally))
  (setq curl-result-error-window (split-window-below nil curl-result-output-window))
  (set-window-buffer curl-result-output-window curl-output-buffer-name)
  (set-window-buffer curl-result-error-window curl-error-buffer-name)
  (select-window curl-result-output-window))

(defun curl-edit-config-value ()
  "
'edit-indirect-after-commit-functions
"
  (interactive)
  (unwind-protect
      (progn
	(narrow-to-region (line-beginning-position) (line-end-position))
	(when (re-search-forward "\s*\\\"\\(.*\\)\\\"\s*$" nil t)
	  (edit-indirect-region (match-beginning 1)
				(match-end 1)
				t)))

    (widen)))

(defun curl-send-request-to-buffer ()
  (interactive)
  (process-send-string curl-request-target-process
		       "curl -v -K-\n")
  (process-send-string curl-request-target-process
		       (with-current-buffer curl-config-buffer
			 (buffer-string)))
  (process-send-eof curl-request-target-process)
  (process-send-eof curl-request-target-process))

;;;###autoload
(defun curl-select-process (&optional buf)
  (interactive "bBuffer: ")
  (if-let ((proc (get-buffer-process buf)))
      (setq-local curl-process proc)))

;;;###autoload
(defun curl-start-on-shell ()
  (interactive)
  (process-send-string curl-process "curl -v -K-\n"))

;;;###autoload
(defun curl-start-make-process ()
  (interactive)
  (make-process
   :name "curl"
   :buffer (get-buffer-create curl-output-buffer-name)
   :stderr (get-buffer-create curl-error-buffer-name)
   :command `(,curl-executable "-v" "-K-")))

;;;###autoload
(defun curl-send ()
  (interactive)
  (process-send-string curl-process (buffer-string))
  (process-send-eof curl-process))

;;;###autoload
(defun curl-json-to-data-as-region-kill-new ()
  (interactive)
  (kill-new
   (json-encode-string
    (json-encode
     (json-read-from-string
      (buffer-substring-no-properties
       (region-beginning) (region-end)))))))

(define-derived-mode curl-config-mode fundamental-mode
  :group 'curl)

(define-key curl-config-mode-map (kbd "C-c C-s") #'curl-start-on-shell)
(define-key curl-config-mode-map (kbd "C-c C-p") #'curl-start-make-process)
(define-key curl-config-mode-map (kbd "C-c C-l") #'curl-select-process)
(define-key curl-config-mode-map (kbd "C-c C-c") #'curl-send)

(provide 'curl)
;; curl.el ends here

他の言語の場合は cURL を直接サブプロセスとして呼び出す事はあまりしない。標準の機能のラッパーとしてのサードパーティを使う。仮にcURLの機能を使うとしても直接呼び出すのではなく、 libcurl をリンクして使う事が多い。例えばPythonでcURLを使いたい場合、libcurlをリンクしたPycURLというサードパーティライブラリを使う。Emacs Lispにもlibcurlを呼び出す拡張はある。

https://github.com/syohex/emacs-curl

ここで試した方法は、libcurlを使うのではなく、サブコマンドとして呼び出すという、何と言ったらいいか大雑把というか思い切りの良い手段を取った。またEmacsのやり方としてもそれ程悪い方法ではないように思う。Emacsのライブラリは、外部プログラムをサブプロセスとして呼び出して実現している機能が結構あるからだ。この辺りは言語の考え方の特徴が現れていて面白い。

HTTPに拘らない

TCPのコネクション(HTTP/3の場合はUDPだけれど)を作成できれば、HTTPは自分の力で手書きすればいいのかもしれない。Emacsには当然トランスポート層の接続を作るための関数が用意されている。

make-network-process

make-network-process はEmacsが提供する低レベルの関数で、 src/process.c 内で定義されており、Cで実装されている34

(make-network-process
 :name "*TESTING*"
 :buffer "*TESTING*"
 :server nil
 :nowait nil
 :host "localhost"
 :coding '(raw-text-unix . raw-text-unix)
 :family 'ipv4
 :service 8000))

関数を呼び出すと、TCP接続を行ったサブプロセスと、それに関連付けられたバッファが作成される。そのプロセスに対してHTTP形式の文字列を送信する事で、HTTPリクエストを送信できる。またHTTPレスポンスはバッファに書き込まれる。

(process-send-string
  (get-buffer-process (get-buffer "*TESTING*"))
  "GET / HTTP/1.1

")
b'GET / HTTP/1.1\r\n\r\n'
サーバーが受け取ったリクエストのデータ。

network-connection

network-connection は、 make-network-process の更に上位の関数で、作成したバッファが comint-mode になっている。そのため作成されたバッファに生のHTTPリクエストを記述し、=comint-send-input= を呼び出す事で、HTTPリクエストを送信できる。HTTPレスポンスは同じバッファに出力される。

注意点としては comint-send-inputRET に割り当てられている。その場合、改行を入力しようとして RET を押すとリクエストが送信される事になる。これはキー割り当てを工夫する事で解消できるし、 C-q を使ってエスケーピングする事でキャリッジリターンなども入力できる。

(network-connection "localhost" 8000)
GET / HTT/1.1
HTTP/1.1 200 OK
Content-Length: 3

yay
Process Network Connection [localhost 8000] connection broken by remote peer
network-connectionで作られたバッファの例。
b'GET / HTT/1.1\r\n'
サーバーが受け取ったリクエストのデータ。

他にも上位の関数はある

これらの実装はまだ幾つかある。それぞれに必要な機能を提供している。

open-network-stream
lisp/net/network-stream.el.gzで定義。
url-open-stream
lisp/url/url-gw.el.gzで定義。
url-http
lisp/url/url-http.el.gzで定義。

必要に応じて使い分ける事が望ましいが、基本的には前述した url-retrieverequestplz で事足りるため、直接使う機会はあまりないかもしれない。ただ使えるという事だけでも知っておくと、トラブルシューティングやデバッグの助けになる。

まとめ

今回はEmacsの様々なHTTPクライアントを確認した。目的も違えば、使っている機構も、レイヤーも異なる様々な方法でHTTPリクエストを送信し、どのようなデータが送信されたのかを確認した。またOrg-modeとBabelの強みを生かしたHTTPの記述と送信についても確認した。Babelを用いた方法では、改行文字の問題などでハマる箇所もあった。またボディから署名を計算するための小さなEmacs Lispを実装した。

Emacsについてまた知る事ができた。