branch: elpa/graphql-mode
commit 6b68045702d72f72c63c0a4dc7c87221d41452f2
Merge: ee49531935e 2c05aebe2fa
Author: David Vázquez Púa <[email protected]>
Commit: GitHub <[email protected]>
Merge pull request #69 from ArthurHeymans/MultipartUpload
Add file upload support for GraphQL mutations via multipart requests
---
graphql-mode.el | 261 +++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 239 insertions(+), 22 deletions(-)
diff --git a/graphql-mode.el b/graphql-mode.el
index 4cab27700f5..4671efced6c 100644
--- a/graphql-mode.el
+++ b/graphql-mode.el
@@ -31,6 +31,7 @@
;;
;; Additionally, it is able to
;; - Sending GraphQL queries to an end-point URL
+;; - Uploading files with GraphQL mutations (multipart requests)
;;
;; Files with the .graphql and .gql extensions are automatically
;; opened with this mode.
@@ -76,6 +77,25 @@
:type '(repeat sexp)
:group 'graphql)
+(defcustom graphql-upload-files nil
+ "List of files to upload with GraphQL mutation.
+Each element should be a cons cell (VAR-NAME . FILE-PATH) where
+VAR-NAME is the variable name in the GraphQL query and FILE-PATH
+is the path to the file to upload."
+ :tag "GraphQL"
+ :type '(repeat (cons (string :tag "Variable name")
+ (file :tag "File path")))
+ :group 'graphql)
+
+(defcustom graphql-upload-format 'operations
+ "Format for multipart file upload requests.
+- `operations': Use operations/map format (graphql-multipart-request-spec)
+- `form-data': Use direct form fields (query/variables/map as separate fields)"
+ :tag "GraphQL"
+ :type '(choice (const :tag "Operations format" operations)
+ (const :tag "Form-data format" form-data))
+ :group 'graphql)
+
(defun graphql-locate-config (dir)
"Locate a graphql config starting in DIR."
(if-let ((config-dir (locate-dominating-file dir ".graphqlconfig")))
@@ -113,46 +133,224 @@
(push (cons 'variables variables) body))
(json-encode body)))
-(defun graphql--query (query &optional operation variables)
+(defun graphql--make-multipart-boundary ()
+ "Generate a unique boundary string for multipart form data."
+ (format "----GraphQLBoundary%s" (md5 (format "%s%s" (current-time)
(random)))))
+
+(defun graphql-encode-multipart-operations (query operation variables
upload-files boundary)
+ "Encode using operations/map format (graphql-multipart-request-spec).
+
+QUERY is the GraphQL query string.
+OPERATION is the operation name.
+VARIABLES is the variables alist.
+UPLOAD-FILES is a list of (VAR-NAME . FILE-PATH) cons cells.
+BOUNDARY is the multipart boundary string.
+
+Returns the complete multipart body as a unibyte string."
+ (let* ((operations-obj (list (cons 'query query)))
+ (map-obj '())
+ (parts '())
+ (file-index 0))
+
+ ;; Add operation name if provided
+ (when (and operation (not (string= operation "")))
+ (push (cons 'operationName operation) operations-obj))
+
+ ;; Add variables
+ (when variables
+ (push (cons 'variables variables) operations-obj))
+
+ ;; Add operations part
+ (push (format "--%s\r\nContent-Disposition: form-data;
name=\"operations\"\r\nContent-Type: application/json\r\n\r\n%s\r\n"
+ boundary
+ (json-encode (nreverse operations-obj)))
+ parts)
+
+ ;; Build the map object
+ (dolist (upload upload-files)
+ (let* ((var-name (car upload))
+ (field-name (format "%d" file-index)))
+ ;; Add to map: {"0": ["variables.varName"]}
+ (push (cons (intern field-name)
+ (vector (format "variables.%s" var-name)))
+ map-obj)
+ (setq file-index (1+ file-index))))
+
+ ;; Add map part
+ (push (format "--%s\r\nContent-Disposition: form-data;
name=\"map\"\r\nContent-Type: application/json\r\n\r\n%s\r\n"
+ boundary
+ (json-encode map-obj))
+ parts)
+
+ ;; Add file parts
+ (setq file-index 0)
+ (dolist (upload upload-files)
+ (let* ((file-path (cdr upload))
+ (field-name (format "%d" file-index))
+ (filename (file-name-nondirectory file-path))
+ (file-content (with-temp-buffer
+ (set-buffer-multibyte nil)
+ (insert-file-contents-literally file-path)
+ (buffer-string))))
+ (push (format "--%s\r\nContent-Disposition: form-data; name=\"%s\";
filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n"
+ boundary
+ field-name
+ filename)
+ parts)
+ (push file-content parts)
+ (push "\r\n" parts)
+ (setq file-index (1+ file-index))))
+
+ ;; Add final boundary
+ (push (format "--%s--\r\n" boundary) parts)
+
+ ;; Combine all parts into unibyte string
+ (apply 'concat (nreverse parts))))
+
+(defun graphql-encode-multipart-form-data (query operation variables
upload-files boundary)
+ "Encode using direct form-data fields (query/variables/map).
+
+QUERY is the GraphQL query string.
+OPERATION is the operation name.
+VARIABLES is the variables alist.
+UPLOAD-FILES is a list of (VAR-NAME . FILE-PATH) cons cells.
+BOUNDARY is the multipart boundary string.
+
+Returns the complete multipart body as a unibyte string.
+Equivalent to curl -F 'query=...' -F 'variables=...' -F 'map=...' -F
'file=@...'."
+ (let* ((map-obj '())
+ (parts '()))
+
+ ;; Add query part
+ (push (format "--%s\r\nContent-Disposition: form-data;
name=\"query\"\r\n\r\n%s\r\n"
+ boundary
+ query)
+ parts)
+
+ ;; Add operation name if provided
+ (when (and operation (not (string= operation "")))
+ (push (format "--%s\r\nContent-Disposition: form-data;
name=\"operationName\"\r\n\r\n%s\r\n"
+ boundary
+ operation)
+ parts))
+
+ ;; Add variables part
+ (when variables
+ (push (format "--%s\r\nContent-Disposition: form-data;
name=\"variables\"\r\nContent-Type: application/json\r\n\r\n%s\r\n"
+ boundary
+ (json-encode variables))
+ parts))
+
+ ;; Build the map object and add files
+ (dolist (upload upload-files)
+ (let* ((var-name (car upload))
+ (file-path (cdr upload)))
+ ;; Add to map: {"varName": ["variables.varName"]}
+ (push (cons (intern var-name)
+ (vector (format "variables.%s" var-name)))
+ map-obj)
+
+ ;; Add file part
+ (let* ((filename (file-name-nondirectory file-path))
+ (file-content (with-temp-buffer
+ (set-buffer-multibyte nil)
+ (insert-file-contents-literally file-path)
+ (buffer-string))))
+ (push (format "--%s\r\nContent-Disposition: form-data; name=\"%s\";
filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n"
+ boundary
+ var-name
+ filename)
+ parts)
+ (push file-content parts)
+ (push "\r\n" parts))))
+
+ ;; Add map part
+ (push (format "--%s\r\nContent-Disposition: form-data;
name=\"map\"\r\nContent-Type: application/json\r\n\r\n%s\r\n"
+ boundary
+ (json-encode map-obj))
+ parts)
+
+ ;; Add final boundary
+ (push (format "--%s--\r\n" boundary) parts)
+
+ ;; Combine all parts into unibyte string
+ (apply 'concat (nreverse parts))))
+
+(defun graphql-encode-multipart (query operation variables upload-files
boundary)
+ "Encode GraphQL request as multipart form data for file uploads.
+
+QUERY is the GraphQL query string.
+OPERATION is the operation name.
+VARIABLES is the variables alist (files should have nil values).
+UPLOAD-FILES is a list of (VAR-NAME . FILE-PATH) cons cells.
+BOUNDARY is the multipart boundary string.
+
+Returns the complete multipart body as a unibyte string.
+Uses format specified by `graphql-upload-format'."
+ (if (eq graphql-upload-format 'form-data)
+ (graphql-encode-multipart-form-data query operation variables
upload-files boundary)
+ (graphql-encode-multipart-operations query operation variables
upload-files boundary)))
+
+(defun graphql--query (query &optional operation variables upload-files)
"Send QUERY to the server and return the response.
The query is sent as a HTTP POST request to the URL at
`graphql-url'. The query can be any GraphQL definition (query,
mutation or subscription). OPERATION is a name for the
operation. VARIABLES is the JSON string that specifies the values
-of the variables used in the query."
+of the variables used in the query. UPLOAD-FILES is a list of
+\(VAR-NAME . FILE-PATH) cons cells for file uploads."
;; Note that we need to get the value of graphql-url in the current
;; before before we switch to the temporary one.
(let ((url graphql-url))
- (graphql-post-request url query operation variables)))
+ (graphql-post-request url query operation variables upload-files)))
(declare-function request "request")
(declare-function request-response-data "request")
(declare-function request-response--raw-header "request")
-(defun graphql-post-request (url query &optional operation variables)
+(defun graphql-post-request (url query &optional operation variables
upload-files)
"Make post request to graphql server with url and body.
URL hostname, path, search parameters, such as operationName and variables
QUERY query definition(s) of query, mutation, and/or subscription
OPERATION name of the operation if multiple definition is given in QUERY
-VARIABLES list of variables for query operation"
+VARIABLES list of variables for query operation
+UPLOAD-FILES list of (VAR-NAME . FILE-PATH) cons cells for file uploads"
(or (require 'request nil t)
(error "graphql-post-request needs the request package. \
Please install it and try again."))
- (let* ((body (graphql-encode-json query operation variables))
- (headers (append '(("Content-Type" . "application/json"))
graphql-extra-headers)))
- (request url
- :type "POST"
- :data body
- :headers headers
- :parser 'json-read
- :sync t
- :complete (lambda (&rest _)
- (message "%s" (if (string-equal "" operation)
- url
- (format "%s?operationName=%s"
- url operation)))))))
+ (if upload-files
+ ;; Multipart request for file uploads
+ (let* ((boundary (graphql--make-multipart-boundary))
+ (body (graphql-encode-multipart query operation variables
upload-files boundary))
+ (headers (append `(("Content-Type" . ,(format
"multipart/form-data; boundary=%s" boundary)))
+ graphql-extra-headers)))
+ (request url
+ :type "POST"
+ :data body
+ :headers headers
+ :parser 'json-read
+ :sync t
+ :complete (lambda (&rest _)
+ (message "%s" (if (string-equal "" operation)
+ url
+ (format "%s?operationName=%s"
+ url operation))))))
+ ;; Regular JSON request
+ (let* ((body (graphql-encode-json query operation variables))
+ (headers (append '(("Content-Type" . "application/json"))
graphql-extra-headers)))
+ (request url
+ :type "POST"
+ :data body
+ :headers headers
+ :parser 'json-read
+ :sync t
+ :complete (lambda (&rest _)
+ (message "%s" (if (string-equal "" operation)
+ url
+ (format "%s?operationName=%s"
+ url operation))))))))
(defun graphql-beginning-of-query ()
"Move the point to the beginning of the current query."
@@ -225,22 +423,40 @@ Please install it and try again."))
(define-key map (kbd "q") 'quit-window)
map))
+(defun graphql-read-upload-files ()
+ "Interactively read files to upload with GraphQL query.
+Returns a list of (VAR-NAME . FILE-PATH) cons cells."
+ (let ((files '())
+ (continue t))
+ (while continue
+ (let ((var-name (read-string "Variable name (empty to finish): ")))
+ (if (string-empty-p var-name)
+ (setq continue nil)
+ (let ((file-path (read-file-name (format "File for %s: " var-name))))
+ (push (cons var-name file-path) files)))))
+ (nreverse files)))
+
(defun graphql-send-query (&optional prompt)
"Send the current GraphQL query/mutation/subscription to server.
With \\[universal-argument] PROMPT, prompt for
-`graphql-url'/`graphql-variables-file'."
+`graphql-url'/`graphql-variables-file'/`graphql-upload-files'."
(interactive "P")
(let* ((url (or (and (not prompt) graphql-url)
(read-string "GraphQL URL: " graphql-url)))
(var (or (and (not prompt) graphql-variables-file)
- (read-file-name "GraphQL Variables: " nil
graphql-variables-file))))
+ (read-file-name "GraphQL Variables: " nil
graphql-variables-file)))
+ (files (if prompt
+ (when (y-or-n-p "Upload files? ")
+ (graphql-read-upload-files))
+ graphql-upload-files)))
(let ((graphql-url url)
- (graphql-variables-file var))
+ (graphql-variables-file var)
+ (graphql-upload-files files))
(let* ((query (buffer-substring-no-properties (point-min) (point-max)))
(operation (graphql-current-operation))
(variables (graphql-current-variables var))
- (response (graphql--query query operation variables)))
+ (response (graphql--query query operation variables files)))
(with-current-buffer-window
"*GraphQL*" 'display-buffer-pop-up-window nil
(erase-buffer)
@@ -259,6 +475,7 @@ With \\[universal-argument] PROMPT, prompt for
;; binding).
(setq graphql-url url)
(setq graphql-variables-file var)
+ (setq graphql-upload-files files)
nil))
(defvar graphql-mode-map