diff options
| author | Craig Jennings <c@cjennings.net> | 2026-02-18 23:20:28 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-02-18 23:20:28 -0600 |
| commit | b19ad7899fecfb6835e19e23a7069233654c9fc7 (patch) | |
| tree | a8f1747db2a1254d029234a29a45578088305ffc | |
| parent | 6bf2688d0b3e4fef82671fb70e1aa883b0c90018 (diff) | |
feat(restclient): add REST API client for interactive API exploration
restclient.el + restclient-jq integration with SkyFi API templates,
tutorial file, auto key injection from authinfo, 17 ERT tests.
| -rw-r--r-- | data/skyfi-api.rest | 85 | ||||
| -rw-r--r-- | data/tutorial-api.rest | 140 | ||||
| -rw-r--r-- | init.el | 1 | ||||
| -rw-r--r-- | modules/restclient-config.el | 104 | ||||
| -rw-r--r-- | tests/test-restclient-config-inject-skyfi-key.el | 147 | ||||
| -rw-r--r-- | tests/test-restclient-config-new-buffer.el | 62 | ||||
| -rw-r--r-- | tests/test-restclient-config-skyfi-buffer.el | 38 | ||||
| -rw-r--r-- | todo.org | 24 |
8 files changed, 601 insertions, 0 deletions
diff --git a/data/skyfi-api.rest b/data/skyfi-api.rest new file mode 100644 index 00000000..9a5a4267 --- /dev/null +++ b/data/skyfi-api.rest @@ -0,0 +1,85 @@ +# -*- restclient -*- +# +# SkyFi Satellite Imagery API +# https://app.skyfi.com/platform-api/redoc +# +# KEY INJECTION: +# The :skyfi-key variable below is auto-populated from authinfo.gpg +# when you open this file via C-; R s. The key is NEVER stored on disk. +# If you see PLACEHOLDER, run C-; R s again or check your authinfo.gpg. +# +# authinfo.gpg entry format: +# machine app.skyfi.com login apikey password YOUR_API_KEY_HERE + +:skyfi-key = PLACEHOLDER +:skyfi-base = https://app.skyfi.com/platform-api + +# +# ============================================================ +# Archive Search — find available satellite imagery +# ============================================================ +# + +# Search for imagery over an area of interest (AOI) +# Adjust the GeoJSON polygon, date range, and cloud cover as needed +POST :skyfi-base/archive/search +Content-Type: application/json +APIKey: :skyfi-key + +{ + "aoi": { + "type": "Polygon", + "coordinates": [[ + [-90.10, 29.95], + [-90.05, 29.95], + [-90.05, 29.98], + [-90.10, 29.98], + [-90.10, 29.95] + ]] + }, + "dateFrom": "2025-01-01T00:00:00Z", + "dateTo": "2025-12-31T23:59:59Z", + "cloudCover": 20 +} + +# +# ============================================================ +# Pricing — get cost estimate for an archive item +# ============================================================ +# + +# Replace ARCHIVE_ID with an ID from the search results above +# TIP: Use jq to extract IDs: -> jq-set-var :archive-id .[0].id +POST :skyfi-base/pricing +Content-Type: application/json +APIKey: :skyfi-key + +{ + "archiveId": "ARCHIVE_ID_HERE", + "aoi": { + "type": "Polygon", + "coordinates": [[ + [-90.10, 29.95], + [-90.05, 29.95], + [-90.05, 29.98], + [-90.10, 29.98], + [-90.10, 29.95] + ]] + } +} + +# +# ============================================================ +# Orders — place and check orders +# ============================================================ +# + +# Check order status (replace ORDER_ID) +GET :skyfi-base/orders/ORDER_ID_HERE +APIKey: :skyfi-key + +# + +# List recent orders +GET :skyfi-base/orders +APIKey: :skyfi-key diff --git a/data/tutorial-api.rest b/data/tutorial-api.rest new file mode 100644 index 00000000..6820cd87 --- /dev/null +++ b/data/tutorial-api.rest @@ -0,0 +1,140 @@ +# -*- restclient -*- +# +# REST API Tutorial — Free Public APIs +# +# QUICK START: +# 1. Place cursor on any request line (GET, POST, etc.) +# 2. C-c C-c — execute request, results appear below +# 3. C-c C-p — jump to previous request +# 4. C-c C-n — jump to next request +# 5. TAB — hide/show response body +# +# SYNTAX BASICS: +# - Lines starting with # are comments +# - Blank line separates comment/header blocks from the request +# - :var = value defines a variable, use it as :var in requests +# - Requests: METHOD URL, then headers, then body after blank line +# +# VARIABLES: +# Define once, reuse everywhere. Variables persist across requests +# in the same buffer. + +:jsonplaceholder = https://jsonplaceholder.typicode.com +:httpbin = https://httpbin.org + +# +# ============================================================ +# JSONPlaceholder — fake REST API for testing +# ============================================================ +# + +# GET a single post +GET :jsonplaceholder/posts/1 + +# + +# GET all posts by user 1 +GET :jsonplaceholder/posts?userId=1 + +# + +# POST a new post (returns 201 with fake ID) +POST :jsonplaceholder/posts +Content-Type: application/json + +{ + "title": "Testing from Emacs", + "body": "restclient.el is great for API exploration.", + "userId": 1 +} + +# + +# PUT (full update) — replaces post 1 +PUT :jsonplaceholder/posts/1 +Content-Type: application/json + +{ + "id": 1, + "title": "Updated Title", + "body": "Updated body text.", + "userId": 1 +} + +# + +# PATCH (partial update) — only update the title +PATCH :jsonplaceholder/posts/1 +Content-Type: application/json + +{ + "title": "Just the title changed" +} + +# + +# DELETE a post +DELETE :jsonplaceholder/posts/1 + +# +# ============================================================ +# httpbin — HTTP echo service +# ============================================================ +# + +# Echo back request headers (great for debugging auth) +GET :httpbin/headers + +# + +# Send custom headers and see them echoed back +GET :httpbin/headers +X-Custom-Header: hello-from-emacs +Accept: application/json + +# + +# Test Basic Auth (user: testuser, pass: testpass) +# httpbin checks credentials and returns 200 or 401 +GET :httpbin/basic-auth/testuser/testpass +Authorization: Basic dGVzdHVzZXI6dGVzdHBhc3M= + +# + +# See your external IP +GET :httpbin/ip + +# + +# Test different status codes (change 418 to any HTTP status) +GET :httpbin/status/418 + +# + +# POST with form data +POST :httpbin/post +Content-Type: application/x-www-form-urlencoded + +name=Craig&tool=restclient + +# +# ============================================================ +# Tips & Tricks +# ============================================================ +# +# JQ FILTERING (requires jq installed + restclient-jq): +# Add -> jq-set-var :varname .path after a request to capture +# a value from the JSON response into a restclient variable. +# +# MULTI-LINE BODIES: +# Just write the JSON/XML body after a blank line. restclient +# sends everything until the next # comment line. +# +# FILE ORGANIZATION: +# Save related requests together in .rest files (e.g., per API +# or per project). Open them with C-; R o. +# +# WORKFLOW: +# 1. Start with C-; R n (scratch buffer) for quick experiments +# 2. Save working requests to a .rest file for reuse +# 3. Use variables for base URLs and auth tokens @@ -125,6 +125,7 @@ ;; -------------------------- AI Integration And Tools ------------------------- (require 'ai-config) ;; LLM integration with GPTel and friends +(require 'restclient-config) ;; REST API client for API exploration (with-eval-after-load 'gptel (add-to-list 'load-path "~/.emacs.d/gptel-tools") diff --git a/modules/restclient-config.el b/modules/restclient-config.el new file mode 100644 index 00000000..650bb781 --- /dev/null +++ b/modules/restclient-config.el @@ -0,0 +1,104 @@ +;;; restclient-config.el --- REST API Client Configuration -*- lexical-binding: t; coding: utf-8; -*- +;; author: Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; Integrates restclient.el for interactive API exploration from within Emacs. +;; +;; Write HTTP requests in plain text buffers, execute with C-c C-c, see +;; results inline. Supports .rest files with variable substitution for +;; reusable API templates. +;; +;; Includes SkyFi satellite imagery API integration with automatic key +;; injection from authinfo.gpg (key never stored on disk). +;; +;; Keybindings (C-; R prefix): +;; - C-; R n : New scratch *restclient* buffer +;; - C-; R o : Open a .rest file (defaults to data/) +;; - C-; R s : Open SkyFi API template + +;;; Code: + +;; --------------------------------- Constants --------------------------------- + +(defvar cj/restclient-data-dir (expand-file-name "data/" user-emacs-directory) + "Directory containing .rest API template files.") + +;; -------------------------------- use-package -------------------------------- + +(use-package restclient + :ensure t + :defer t + :mode ("\\.rest\\'" . restclient-mode)) + +(use-package restclient-jq + :ensure t + :if (executable-find "jq") + :after restclient) + +;; ----------------------------- Private Helpers ------------------------------- + +(defun cj/restclient--inject-skyfi-key () + "Replace the :skyfi-key variable line with the real key from authinfo. +Only acts when the buffer is in `restclient-mode', the filename ends +in \"skyfi-api.rest\", and a :skyfi-key line exists. If the auth +lookup returns nil, leaves the buffer unchanged." + (when (and (derived-mode-p 'restclient-mode) + buffer-file-name + (string-match-p "skyfi-api\\.rest\\'" buffer-file-name)) + (let ((key (condition-case nil + (cj/skyfi-api-key) + (error nil)))) + (when key + (save-excursion + (goto-char (point-min)) + (when (re-search-forward "^:skyfi-key = .*$" nil t) + (replace-match (format ":skyfi-key = %s" key)))))))) + +;; ----------------------------- Public Functions ------------------------------ + +(defun cj/skyfi-api-key () + "Fetch SkyFi API key from authinfo.gpg." + (cj/auth-source-secret "app.skyfi.com" "apikey")) + +(defun cj/restclient-new-buffer () + "Open a scratch *restclient* buffer in `restclient-mode'." + (interactive) + (let ((buf (get-buffer-create "*restclient*"))) + (switch-to-buffer buf) + (unless (derived-mode-p 'restclient-mode) + (restclient-mode)))) + +(defun cj/restclient-open-file () + "Prompt for a .rest file to open, defaulting to the data/ directory." + (interactive) + (let ((file (read-file-name "Open .rest file: " cj/restclient-data-dir nil t nil + (lambda (f) + (or (file-directory-p f) + (string-match-p "\\.rest\\'" f)))))) + (find-file file))) + +(defun cj/restclient-skyfi-buffer () + "Open the SkyFi API template file. +Runs the key-injection hook after opening." + (interactive) + (let ((skyfi-file (expand-file-name "skyfi-api.rest" cj/restclient-data-dir))) + (unless (file-exists-p skyfi-file) + (user-error "SkyFi template not found: %s" skyfi-file)) + (find-file skyfi-file) + (cj/restclient--inject-skyfi-key))) + +;; -------------------------------- Keybindings -------------------------------- + +(global-set-key (kbd "C-; R n") #'cj/restclient-new-buffer) +(global-set-key (kbd "C-; R o") #'cj/restclient-open-file) +(global-set-key (kbd "C-; R s") #'cj/restclient-skyfi-buffer) + +(with-eval-after-load 'which-key + (which-key-add-key-based-replacements + "C-; R" "REST client" + "C-; R n" "new scratch buffer" + "C-; R o" "open .rest file" + "C-; R s" "SkyFi API template")) + +(provide 'restclient-config) +;;; restclient-config.el ends here diff --git a/tests/test-restclient-config-inject-skyfi-key.el b/tests/test-restclient-config-inject-skyfi-key.el new file mode 100644 index 00000000..d471b913 --- /dev/null +++ b/tests/test-restclient-config-inject-skyfi-key.el @@ -0,0 +1,147 @@ +;;; test-restclient-config-inject-skyfi-key.el --- Tests for cj/restclient--inject-skyfi-key -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/restclient--inject-skyfi-key function. +;; Replaces the :skyfi-key placeholder in a restclient buffer with the real +;; API key from authinfo. Tests cover Normal, Boundary, and Error cases. + +;;; Code: + +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'restclient-config) + +;; --------------------------------------------------------------------------- +;; Test Helpers +;; --------------------------------------------------------------------------- + +(defvar test-inject--fake-key "sk_test_fake_key_12345" + "Fake API key used in tests.") + +(defmacro test-inject--with-skyfi-buffer (content &rest body) + "Create a temp buffer with CONTENT simulating skyfi-api.rest, then run BODY. +Sets buffer-file-name to skyfi-api.rest and activates restclient-mode. +Binds `cj/skyfi-api-key' to return `test-inject--fake-key'." + (declare (indent 1)) + `(with-temp-buffer + (insert ,content) + (setq buffer-file-name (expand-file-name "data/skyfi-api.rest" user-emacs-directory)) + (restclient-mode) + (cl-letf (((symbol-function 'cj/skyfi-api-key) + (lambda () test-inject--fake-key))) + ,@body))) + +;; --------------------------------------------------------------------------- +;;; Normal Cases +;; --------------------------------------------------------------------------- + +(ert-deftest test-inject-skyfi-key-replaces-placeholder () + "Replaces :skyfi-key = PLACEHOLDER with real key." + (test-inject--with-skyfi-buffer + ":skyfi-key = PLACEHOLDER\n#\nGET https://example.com\n" + (cj/restclient--inject-skyfi-key) + (goto-char (point-min)) + (should (string-match-p (format ":skyfi-key = %s" test-inject--fake-key) + (buffer-string))))) + +(ert-deftest test-inject-skyfi-key-preserves-other-content () + "Rest of buffer content unchanged after injection." + (let ((rest-content "# SkyFi API\nGET https://app.skyfi.com/platform-api/\nAPIKey: :skyfi-key\n")) + (test-inject--with-skyfi-buffer + (concat ":skyfi-key = PLACEHOLDER\n" rest-content) + (cj/restclient--inject-skyfi-key) + (should (string-match-p "# SkyFi API" (buffer-string))) + (should (string-match-p "APIKey: :skyfi-key" (buffer-string)))))) + +(ert-deftest test-inject-skyfi-key-only-replaces-skyfi-key-line () + "Does not modify other restclient variable lines." + (test-inject--with-skyfi-buffer + ":skyfi-key = PLACEHOLDER\n:other-var = keep-me\n" + (cj/restclient--inject-skyfi-key) + (should (string-match-p ":other-var = keep-me" (buffer-string))))) + +;; --------------------------------------------------------------------------- +;;; Boundary Cases +;; --------------------------------------------------------------------------- + +(ert-deftest test-inject-skyfi-key-no-key-line-no-error () + "Buffer with no :skyfi-key line — no change, no error." + (test-inject--with-skyfi-buffer + "# Just comments\nGET https://example.com\n" + (let ((before (buffer-string))) + (cj/restclient--inject-skyfi-key) + (should (string= before (buffer-string)))))) + +(ert-deftest test-inject-skyfi-key-already-has-value () + "Buffer where :skyfi-key already has a real value — still replaces (idempotent)." + (test-inject--with-skyfi-buffer + ":skyfi-key = old_real_key_abc\n" + (cj/restclient--inject-skyfi-key) + (should (string-match-p (format ":skyfi-key = %s" test-inject--fake-key) + (buffer-string))))) + +(ert-deftest test-inject-skyfi-key-empty-buffer () + "Empty buffer — no error." + (test-inject--with-skyfi-buffer "" + (cj/restclient--inject-skyfi-key) + (should (string= "" (buffer-string))))) + +(ert-deftest test-inject-skyfi-key-only-first-occurrence () + "Multiple :skyfi-key lines — only first replaced." + (test-inject--with-skyfi-buffer + ":skyfi-key = PLACEHOLDER\n:skyfi-key = SECOND\n" + (cj/restclient--inject-skyfi-key) + (let ((content (buffer-string))) + (should (string-match-p (format ":skyfi-key = %s" test-inject--fake-key) content)) + (should (string-match-p ":skyfi-key = SECOND" content))))) + +;; --------------------------------------------------------------------------- +;;; Error Cases +;; --------------------------------------------------------------------------- + +(ert-deftest test-inject-skyfi-key-wrong-mode-no-replacement () + "Wrong major mode — no replacement happens." + (with-temp-buffer + (insert ":skyfi-key = PLACEHOLDER\n") + (setq buffer-file-name (expand-file-name "data/skyfi-api.rest" user-emacs-directory)) + (fundamental-mode) + (let ((before (buffer-string))) + (cj/restclient--inject-skyfi-key) + (should (string= before (buffer-string)))))) + +(ert-deftest test-inject-skyfi-key-wrong-filename-no-replacement () + "Wrong filename — no replacement happens." + (with-temp-buffer + (insert ":skyfi-key = PLACEHOLDER\n") + (setq buffer-file-name "/tmp/other-file.rest") + (restclient-mode) + (let ((before (buffer-string))) + (cj/restclient--inject-skyfi-key) + (should (string= before (buffer-string)))))) + +(ert-deftest test-inject-skyfi-key-no-filename-no-replacement () + "No filename (scratch buffer) — no replacement happens." + (with-temp-buffer + (insert ":skyfi-key = PLACEHOLDER\n") + (restclient-mode) + (setq buffer-file-name nil) + (let ((before (buffer-string))) + (cj/restclient--inject-skyfi-key) + (should (string= before (buffer-string)))))) + +(ert-deftest test-inject-skyfi-key-auth-returns-nil-no-error () + "Auth-source returns nil — no error, no replacement." + (with-temp-buffer + (insert ":skyfi-key = PLACEHOLDER\n") + (setq buffer-file-name (expand-file-name "data/skyfi-api.rest" user-emacs-directory)) + (restclient-mode) + (cl-letf (((symbol-function 'cj/skyfi-api-key) + (lambda () nil))) + (let ((before (buffer-string))) + (cj/restclient--inject-skyfi-key) + (should (string= before (buffer-string))))))) + +(provide 'test-restclient-config-inject-skyfi-key) +;;; test-restclient-config-inject-skyfi-key.el ends here diff --git a/tests/test-restclient-config-new-buffer.el b/tests/test-restclient-config-new-buffer.el new file mode 100644 index 00000000..a89ec3a3 --- /dev/null +++ b/tests/test-restclient-config-new-buffer.el @@ -0,0 +1,62 @@ +;;; test-restclient-config-new-buffer.el --- Tests for cj/restclient-new-buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/restclient-new-buffer function. +;; Creates a scratch *restclient* buffer in restclient-mode. +;; Covers Normal and Boundary cases. + +;;; Code: + +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'restclient-config) + +;;; Normal Cases + +(ert-deftest test-restclient-new-buffer-creates-buffer () + "Creates a buffer named *restclient*." + (unwind-protect + (progn + (cj/restclient-new-buffer) + (should (get-buffer "*restclient*"))) + (when (get-buffer "*restclient*") + (kill-buffer "*restclient*")))) + +(ert-deftest test-restclient-new-buffer-sets-mode () + "Buffer is in restclient-mode." + (unwind-protect + (progn + (cj/restclient-new-buffer) + (with-current-buffer "*restclient*" + (should (eq major-mode 'restclient-mode)))) + (when (get-buffer "*restclient*") + (kill-buffer "*restclient*")))) + +(ert-deftest test-restclient-new-buffer-switches-to-buffer () + "Switches to the *restclient* buffer." + (unwind-protect + (progn + (cj/restclient-new-buffer) + (should (string= (buffer-name (current-buffer)) "*restclient*"))) + (when (get-buffer "*restclient*") + (kill-buffer "*restclient*")))) + +;;; Boundary Cases + +(ert-deftest test-restclient-new-buffer-reuses-existing () + "Reuses existing *restclient* buffer instead of creating a duplicate." + (unwind-protect + (let ((buf (get-buffer-create "*restclient*"))) + (with-current-buffer buf + (restclient-mode) + (insert "# existing content")) + (cj/restclient-new-buffer) + (should (eq (current-buffer) buf)) + (should (string-match-p "existing content" (buffer-string)))) + (when (get-buffer "*restclient*") + (kill-buffer "*restclient*")))) + +(provide 'test-restclient-config-new-buffer) +;;; test-restclient-config-new-buffer.el ends here diff --git a/tests/test-restclient-config-skyfi-buffer.el b/tests/test-restclient-config-skyfi-buffer.el new file mode 100644 index 00000000..7685c77c --- /dev/null +++ b/tests/test-restclient-config-skyfi-buffer.el @@ -0,0 +1,38 @@ +;;; test-restclient-config-skyfi-buffer.el --- Tests for cj/restclient-skyfi-buffer -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/restclient-skyfi-buffer function. +;; Opens the SkyFi API template file. +;; Covers Normal and Error cases. + +;;; Code: + +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'restclient-config) + +;;; Normal Cases + +(ert-deftest test-restclient-skyfi-buffer-opens-file () + "Opens existing skyfi-api.rest file and switches to it." + (let ((skyfi-file (expand-file-name "data/skyfi-api.rest" user-emacs-directory))) + (when (file-exists-p skyfi-file) + (unwind-protect + (progn + (cj/restclient-skyfi-buffer) + (should (string-match-p "skyfi-api\\.rest" + (buffer-file-name (current-buffer))))) + (when-let ((buf (get-file-buffer skyfi-file))) + (kill-buffer buf)))))) + +;;; Error Cases + +(ert-deftest test-restclient-skyfi-buffer-missing-file-signals-error () + "Signals user-error when skyfi-api.rest does not exist." + (let ((cj/restclient-data-dir "/tmp/nonexistent-restclient-test-dir/")) + (should-error (cj/restclient-skyfi-buffer) :type 'user-error))) + +(provide 'test-restclient-config-skyfi-buffer) +;;; test-restclient-config-skyfi-buffer.el ends here @@ -320,6 +320,30 @@ Changes in progress (modules/auth-config.el): - Use external pinentry (pinentry-dmenu) in GUI - Requires env-terminal-p from host-environment module +** API & Web Services + +*** VERIFY [#B] Test and review restclient.el implementation + +Test the new REST API client integration in a running Emacs session. + +**Keybindings to test:** +- C-; R n — new scratch *restclient* buffer (should open in restclient-mode) +- C-; R o — open .rest file (should default to data/ directory) +- C-; R s — open SkyFi template (should auto-inject API key from authinfo) + +**Functional tests:** +1. Open tutorial-api.rest, run JSONPlaceholder GET (C-c C-c) — verify response inline +2. Run POST example — verify 201 response with fake ID +3. Run httpbin header echo — verify custom headers echoed back +4. Navigate between requests with C-c C-n / C-c C-p +5. Test jq filtering (requires jq installed): restclient-jq loaded? +6. Open scratch buffer (C-; R n), type a request manually, execute +7. which-key shows "REST client" menu under C-; R + +**SkyFi key injection (if authinfo entry exists):** +- C-; R s should replace :skyfi-key = PLACEHOLDER with real key +- Key should NOT be written to disk (verify file still shows PLACEHOLDER) + * Emacs Resolved ** DONE [#B] Investigate missing yasnippet configuration CLOSED: [2026-02-16 Mon] |
