diff --git a/docs/docs/usage/public-methods.md b/docs/docs/usage/public-methods.md index 8e91df7..5e292ed 100644 --- a/docs/docs/usage/public-methods.md +++ b/docs/docs/usage/public-methods.md @@ -6,6 +6,10 @@ All public methods are available via the `kulala` module. `require('kulala').run()` runs the current request. +### replay + +`require('kulala').replay()` replays the last run request. + ### copy `require('kulala').copy()` copies the current request diff --git a/docs/docs/usage/request-variables.md b/docs/docs/usage/request-variables.md new file mode 100644 index 0000000..aff5066 --- /dev/null +++ b/docs/docs/usage/request-variables.md @@ -0,0 +1,76 @@ +# Request Variables + +The definition syntax of request variables is just like a single-line comment, +and follows `# @name REQUEST_NAME` just as metadata. + +```http +POST https://httpbin.org/post HTTP/1.1 +Content-Type: application/x-www-form-urlencoded +# @name THIS_IS_AN_EXAMPLE_REQUEST_NAME + +name=foo&password=bar +``` + +You can think of request variable as attaching a name metadata to the underlying request, +and this kind of requests can be called with Named Request, +Other requests can use `THIS_IS_AN_EXAMPLE_REQUEST_NAME` as an +identifier to reference the expected part of the named request or its latest response. + +**NOTE:** If you want to refer the response of a named request, +you need to manually trigger the named request to retrieve its response first, +otherwise the plain text of +variable reference like `{{THIS_IS_AN_EXAMPLE_REQUEST_NAME.response.body.$.id}}` will be sent instead. + +The reference syntax of a request variable is a bit more complex than other kinds of custom variables. + +## Request Variable Reference Syntax + +The request variable reference syntax follows `{{REQUEST_NAME.(response|request).(body|headers).(*|JSONPath|XPath|Header Name)}}`. + +You have two reference part choices of the `response` or `request`: `body` and `headers`. + +For `body` part, you can use JSONPath and XPath to extract specific property or attribute. + +## Example + +if a JSON response returns `body` `{"id": "mock"}`, you can set the JSONPath part to `$.id` to reference the `id`. + +For `headers` part, you can specify the header name to extract the header value. + +The header name is case-sensitive for `response` part, and all lower-cased for `request` part. + +If the *JSONPath* or *XPath* of `body`, or *Header Name* of `headers` can't be resolved, +the plain text of variable reference will be sent instead. +And in this case, +diagnostic information will be displayed to help you to inspect this. + +Below is a sample of request variable definitions and references in an http file. + +```http +POST https://httpbin.org/post HTTP/1.1 +accept: application/json +# @name REQUEST_ONE + +{ + "token": "foobar" +} + +### + +POST https://httpbin.org/post HTTP/1.1 +accept: application/json +# @name REQUEST_TWO + +{ + "token": "{{REQUEST_ONE.response.body.$.json.token}}" +} + +### + +POST https://httpbin.org/post HTTP/1.1 +accept: application/json + +{ + "date_header": "{{REQUEST_TWO.response.headers['Date']}}" +} +``` diff --git a/lua/kulala/cmd/init.lua b/lua/kulala/cmd/init.lua index c77b466..5e85343 100644 --- a/lua/kulala/cmd/init.lua +++ b/lua/kulala/cmd/init.lua @@ -25,7 +25,9 @@ M.run = function(result, callback) end for _, metadata in ipairs(result.metadata) do if metadata then - if metadata.name == "env-json-key" then + if metadata.name == "name" then + INT_PROCESSING.set_env_for_named_request(metadata.value, body) + elseif metadata.name == "env-json-key" then INT_PROCESSING.env_json_key(metadata.value, body) elseif metadata.name == "env-header-key" then INT_PROCESSING.env_header_key(metadata.value) diff --git a/lua/kulala/db/init.lua b/lua/kulala/db/init.lua new file mode 100644 index 0000000..82045f7 --- /dev/null +++ b/lua/kulala/db/init.lua @@ -0,0 +1,7 @@ +local M = {} + +M.data = { + env = {}, +} + +return M diff --git a/lua/kulala/external_processing/init.lua b/lua/kulala/external_processing/init.lua index 9863e64..c960def 100644 --- a/lua/kulala/external_processing/init.lua +++ b/lua/kulala/external_processing/init.lua @@ -1,4 +1,5 @@ local FS = require("kulala.utils.fs") +local DB = require("kulala.db") local M = {} @@ -20,7 +21,7 @@ M.env_stdin_cmd = function(cmdstring, contents) vim.notify("env_stdin_cmd --> Command failed: " .. cmd[2] .. ".", vim.log.levels.ERROR) return else - vim.env[env_name] = res + DB.data.env[env_name] = res end end diff --git a/lua/kulala/globals/init.lua b/lua/kulala/globals/init.lua index 21eec09..b631c46 100644 --- a/lua/kulala/globals/init.lua +++ b/lua/kulala/globals/init.lua @@ -2,7 +2,7 @@ local FS = require("kulala.utils.fs") local M = {} -M.VERSION = "2.8.2" +M.VERSION = "2.9.0" M.UI_ID = "kulala://ui" M.HEADERS_FILE = FS.get_plugin_tmp_dir() .. "/headers.txt" M.BODY_FILE = FS.get_plugin_tmp_dir() .. "/body.txt" diff --git a/lua/kulala/init.lua b/lua/kulala/init.lua index a983486..8f4c2f4 100644 --- a/lua/kulala/init.lua +++ b/lua/kulala/init.lua @@ -18,6 +18,10 @@ M.run = function() UI:open() end +M.replay = function() + UI:replay() +end + M.copy = function() UI:copy() end diff --git a/lua/kulala/internal_processing/init.lua b/lua/kulala/internal_processing/init.lua index 4fd6ccf..15afb6d 100644 --- a/lua/kulala/internal_processing/init.lua +++ b/lua/kulala/internal_processing/init.lua @@ -1,5 +1,6 @@ local FS = require("kulala.utils.fs") local GLOBALS = require("kulala.globals") +local DB = require("kulala.db") local M = {} -- Function to access a nested key in a table dynamically @@ -22,18 +23,41 @@ local get_headers_as_table = function() for _, header in ipairs(lines) do if header:find(":") ~= nil then local kv = vim.split(header, ":") - local key = kv[1]:lower() + local key = kv[1] -- the value should be everything after the first colon -- but we can't use slice and join because the value might contain colons local value = header:sub(#key + 2) - headers_table[key] = value + headers_table[key] = vim.trim(value) end end return headers_table end -M.env_header_key = function(cmd) +local get_lower_headers_as_table = function() local headers = get_headers_as_table() + local headers_table = {} + for key, value in pairs(headers) do + headers_table[key:lower()] = value + end + return headers_table +end + +M.set_env_for_named_request = function(name, body) + local named_request = { + response = { + headers = get_headers_as_table(), + body = body, + }, + request = { + headers = DB.data.current_request.headers, + body = DB.data.current_request.body, + }, + } + DB.data.env[name] = named_request +end + +M.env_header_key = function(cmd) + local headers = get_lower_headers_as_table() local kv = vim.split(cmd, " ") local header_key = kv[2] local variable_name = kv[1] @@ -41,7 +65,7 @@ M.env_header_key = function(cmd) if value == nil then vim.notify("env-header-key --> Header not found.", vim.log.levels.ERROR) else - vim.fn.setenv(variable_name, value) + DB.data.env[variable_name] = value end end @@ -52,7 +76,7 @@ M.env_json_key = function(cmd, body) else local kv = vim.split(cmd, " ") local value = get_nested_value(json, kv[2]) - vim.fn.setenv(kv[1], value) + DB.data.env[kv[1]] = value end end diff --git a/lua/kulala/parser/env.lua b/lua/kulala/parser/env.lua index 9c5c9f9..be425c7 100644 --- a/lua/kulala/parser/env.lua +++ b/lua/kulala/parser/env.lua @@ -1,6 +1,7 @@ local FS = require("kulala.utils.fs") local GLOBAL_STORE = require("kulala.global_store") local DYNAMIC_VARS = require("kulala.parser.dynamic_vars") +local DB = require("kulala.db") local M = {} @@ -40,6 +41,9 @@ M.get_env = function() end end end + for key, value in pairs(DB.data.env) do + env[key] = value + end return env end diff --git a/lua/kulala/parser/init.lua b/lua/kulala/parser/init.lua index 93cb5a0..f4d6445 100644 --- a/lua/kulala/parser/init.lua +++ b/lua/kulala/parser/init.lua @@ -1,11 +1,13 @@ -local FS = require("kulala.utils.fs") -local GLOBALS = require("kulala.globals") -local GLOBAL_STORE = require("kulala.global_store") local CONFIG = require("kulala.config") +local DB = require("kulala.db") local DYNAMIC_VARS = require("kulala.parser.dynamic_vars") -local STRING_UTILS = require("kulala.utils.string") local ENV_PARSER = require("kulala.parser.env") +local FS = require("kulala.utils.fs") +local GLOBALS = require("kulala.globals") +local GLOBAL_STORE = require("kulala.global_store") local GRAPHQL_PARSER = require("kulala.parser.graphql") +local REQUEST_VARIABLES = require("kulala.parser.request_variables") +local STRING_UTILS = require("kulala.utils.string") local PLUGIN_TMP_DIR = FS.get_plugin_tmp_dir() local M = {} @@ -32,6 +34,8 @@ local function parse_string_variables(str, variables) value = variables[variable_name] elseif env[variable_name] then value = env[variable_name] + elseif REQUEST_VARIABLES.parse(variable_name) then + value = REQUEST_VARIABLES.parse(variable_name) else value = "{{" .. variable_name .. "}}" vim.notify( @@ -282,6 +286,8 @@ function M.parse() local document_variables, requests = M.get_document() local req = M.get_request_at_cursor(requests) + DB.data.previous_request = DB.data.current_request + document_variables = extend_document_variables(document_variables, req) res.url = parse_url(req.url, document_variables) @@ -394,6 +400,7 @@ function M.parse() if CONFIG.get().debug then FS.write_file(PLUGIN_TMP_DIR .. "/request.txt", table.concat(res.cmd, " ")) end + DB.data.current_request = res return res end diff --git a/lua/kulala/parser/request_variables.lua b/lua/kulala/parser/request_variables.lua new file mode 100644 index 0000000..21d78fb --- /dev/null +++ b/lua/kulala/parser/request_variables.lua @@ -0,0 +1,134 @@ +local DB = require("kulala.db") + +local M = {} + +local function get_match(str) + -- there is no logical or operator in lua regex + -- so we have to use multiple patterns to match the string + local patterns = { + "^([%w_]+)%.(request)%.(headers)(.*)", + "^([%w_]+)%.(request)%.(body)%.(.*)", + "^([%w_]+)%.(response)%.(headers)(.*)", + "^([%w_]+)%.(response)%.(body)%.(.*)", + } + for _, p in ipairs(patterns) do + local path_name, path_method, path_type, subpath = string.match(str, p) + if path_name then + return path_name, path_method, path_type, subpath + end + end + return nil, nil, nil, nil +end + +local function is_json_path(subpath) + local p = "^%$.*" + return string.match(subpath, p) +end + +local function is_xpath_path(subpath) + local p = "^//.*" + return string.match(subpath, p) +end + +local function get_xpath_body_value_from_path(name, method, path) + local base_table = DB.data.env[name] + if not base_table then + return nil + end + if not base_table[method] then + return nil + end + if not base_table[method].body then + return nil + end + path = string.gsub(path, "^(%w+)%.(response|request)%.body%.", "") + local body = base_table[method].body + local cmd = { "xmllint", "--xpath", path, "-" } + local result = vim.system(cmd, { stdin = body, text = true }):wait().stdout + return result +end + +local function get_json_body_value_from_path(name, method, subpath) + local base_table = DB.data.env[name] + if not base_table then + return nil + end + if not base_table[method] then + return nil + end + if not base_table[method].body then + return nil + end + + subpath = string.gsub(subpath, "^%$%.", "") + + local result = vim.fn.json_decode(base_table[method].body) + + local path_parts = {} + + for part in string.gmatch(subpath, "[^%.%[%]\"']+") do + table.insert(path_parts, part) + end + + for _, key in ipairs(path_parts) do + if result[key] then + result = result[key] + else + return nil -- Return nil if any part of the path is not found + end + end + + return result +end + +local function get_header_value_from_path(name, method, subpath) + local base_table = DB.data.env[name] + if not base_table then + return nil + end + if not base_table[method] then + return nil + end + if not base_table[method].headers then + return nil + end + local result = base_table[method].headers + local path_parts = {} + + -- Split the path into parts + for part in string.gmatch(subpath, "[^%.%[%]\"']+") do + table.insert(path_parts, part) + end + + for _, key in ipairs(path_parts) do + if result[key] then + result = result[key] + else + return nil -- Return nil if any part of the path is not found + end + end + + return result +end + +M.parse = function(path) + local path_name, path_method, path_type, path_subpath = get_match(path) + + if not path_name or not path_method or not path_type or not path_subpath then + return nil + end + + if path_type == "headers" then + return get_header_value_from_path(path_name, path_method, path_subpath) + elseif path_type == "body" then + if is_json_path(path_subpath) then + return get_json_body_value_from_path(path_name, path_method, path_subpath) + elseif is_xpath_path(path_subpath) then + return get_xpath_body_value_from_path(path_name, path_method, path_subpath) + end + end + + return nil +end + +return M diff --git a/lua/kulala/ui/init.lua b/lua/kulala/ui/init.lua index 8ad6255..055de5b 100644 --- a/lua/kulala/ui/init.lua +++ b/lua/kulala/ui/init.lua @@ -4,6 +4,7 @@ local INLAY = require("kulala.inlay") local PARSER = require("kulala.parser") local CMD = require("kulala.cmd") local FS = require("kulala.utils.fs") +local DB = require("kulala.db") local M = {} local open_buffer = function() @@ -162,6 +163,31 @@ M.show_headers = function() end end +M.replay = function() + local result = DB.data.current_request + if result == nil then + vim.notify("No request to replay", vim.log.levels.WARN, { title = "kulala" }) + return + end + vim.schedule(function() + CMD.run(result, function(success) + if not success then + vim.notify("Failed to replay request", vim.log.levels.ERROR, { title = "kulala" }) + return + else + if not buffer_exists() then + open_buffer() + end + if CONFIG.get().default_view == "body" then + M.show_body() + else + M.show_headers() + end + end + end) + end) +end + M.toggle_headers = function() local cfg = CONFIG.get() if cfg.default_view == "headers" then diff --git a/package.json b/package.json index db7d27e..111af16 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,4 @@ { "name": "kulala.nvim", - "version": "2.8.2" + "version": "2.9.0" }