From 1b2d3454207669996544aff3a5f2252eddb806c5 Mon Sep 17 00:00:00 2001 From: Morten Olsen Date: Sun, 18 May 2025 20:31:23 +0200 Subject: [PATCH] feat: support template in all code blocks --- README.md | 29 ++-- src/execution/handlers/handlers.code.ts | 34 ++++ src/execution/handlers/handlers.http.ts | 153 +++++++++--------- .../{handlers.file.ts => handlers.md.ts} | 0 src/execution/handlers/handlers.ts | 4 +- 5 files changed, 132 insertions(+), 88 deletions(-) create mode 100644 src/execution/handlers/handlers.code.ts rename src/execution/handlers/{handlers.file.ts => handlers.md.ts} (100%) diff --git a/README.md b/README.md index aa0e201..233edaf 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ access-control-allow-origin: * connection: keep-alive content-length: 559 content-type: application/json -date: Sun, 18 May 2025 17:17:15 GMT +date: Sun, 18 May 2025 18:31:46 GMT server: gunicorn/19.9.0 { @@ -129,7 +129,7 @@ server: gunicorn/19.9.0 "Host": "httpbin.org", "Sec-Fetch-Mode": "cors", "User-Agent": "node", - "X-Amzn-Trace-Id": "Root=1-682a161b-6f8d778138665a8f22ffbe94" + "X-Amzn-Trace-Id": "Root=1-682a2792-7df702ce77a3b3696937eaeb" }, "json": { "greeting": "Hello, http.md!" @@ -292,7 +292,7 @@ GET https://httpbin.org/anything/{{responses.createItem.body.json.name}} GET https://httpbin.org/status/201 ``` -The request to `/status/201` completed with status code: **{{response.status}}**. +The request to `/status/201` completed with status code: ****. ```` ## Managing Documents @@ -324,7 +324,7 @@ Let's include some shared requests: ::md[./_shared_requests.md] -The shared GET request returned: {{responses.sharedGetRequest.status}} +The shared GET request returned: Now, a request specific to this document: @@ -332,7 +332,7 @@ Now, a request specific to this document: POST https://httpbin.org/post Content-Type: application/json -{"dataFromMain": "someValue", "sharedUrl": "{{requests.sharedGetRequest.url}}"} +{"dataFromMain": "someValue", "sharedUrl": ""} ``` ::response @@ -356,8 +356,8 @@ httpmd build mydoc.md output.md -i baseUrl=https://api.production.example.com -i ````markdown ```http -GET {{input.baseUrl}}/users/1 -Authorization: Bearer {{input.apiKey}} +GET /users/1 +Authorization: Bearer ``` ::response @@ -406,8 +406,8 @@ You can configure the behavior of each `http` code block by adding options to it ````markdown ```http id=complexRequest,json,yaml,hidden -POST {{input.apiEndpoint}}/data -X-API-Key: {{input.apiKey}} +POST /data +X-API-Key: Content-Type: application/json # Request body written in YAML, will be converted to JSON @@ -445,6 +445,17 @@ The `::md` directive embeds another markdown document. * `hidden`: If present, the actual content (markdown) of the embedded document will not be rendered in the output. However, any `http` requests within the embedded document *are still processed*, and their `request` and `response` data become available in the parent document's templating context (via `requests.id` and `responses.id`). This is useful if you only want to execute the requests from an included file (e.g., a common setup sequence) and use their results, without displaying the embedded file's content. * Example: `::md[./setup_requests.md]{hidden}` +#### `::input[{name}]` Directive Options + +The `::input` directive is used to declare expected input variables + +* **Variable Name:** The first argument (required) is the name of the variable + * Example: `::input[myVariable]` will define `input.myVariable` +* `required`: If present it will require that the variable is provided +* `default={value}`: Defines the default value if no value has been provided +* `format=string|number|bool|json|date`: If provided the value will be parsed using the specified format +* \`\` + ## Command-Line Interface (CLI) The `httpmd` tool provides the following commands: diff --git a/src/execution/handlers/handlers.code.ts b/src/execution/handlers/handlers.code.ts new file mode 100644 index 0000000..f397a3c --- /dev/null +++ b/src/execution/handlers/handlers.code.ts @@ -0,0 +1,34 @@ +import Handlebars from "handlebars"; +import { ExecutionHandler } from "../execution.js"; + +const codeHandler: ExecutionHandler = ({ + node, + addStep, +}) => { + if (node.type !== 'code' || node.lang === 'http') { + return; + } + const optionParts = node.meta?.split(',') || []; + const options = Object.fromEntries( + optionParts.filter(Boolean).map((option) => { + const [key, value] = option.split('='); + return [key.trim(), value?.trim() || true]; + }) + ); + + addStep({ + type: 'code', + node, + action: async ({ context }) => { + node.meta = undefined; + if (options['no-tmpl'] === true) { + return; + } + const template = Handlebars.compile(node.value); + const content = template(context); + node.value = content; + }, + }); +}; + +export { codeHandler }; diff --git a/src/execution/handlers/handlers.http.ts b/src/execution/handlers/handlers.http.ts index b585186..464ab9b 100644 --- a/src/execution/handlers/handlers.http.ts +++ b/src/execution/handlers/handlers.http.ts @@ -6,85 +6,82 @@ const httpHandler: ExecutionHandler = ({ node, addStep, }) => { - if (node.type === 'code') { - if (node.lang !== 'http') { - return; - } - const optionParts = node.meta?.split(',') || []; - const options = Object.fromEntries( - optionParts.filter(Boolean).map((option) => { - const [key, value] = option.split('='); - return [key.trim(), value?.trim() || true]; - }) - ); - - addStep({ - type: 'http', - node, - action: async ({ context }) => { - if (options.disable === true) { - return; - } - const template = Handlebars.compile(node.value); - const content = template(context); - const [head, body] = content.split('\n\n'); - const [top, ...headerItems] = head.split('\n'); - const [method, url] = top.split(' '); - - const headers = Object.fromEntries( - headerItems.map((header) => { - const [key, value] = header.split(':'); - return [key.trim(), value?.trim() || '']; - }) - ); - - let parsedBody = body; - if (options.format === 'yaml') { - try { - const parsed = YAML.parse(body); - parsedBody = JSON.stringify(parsed); - } catch (error) { - parsedBody = `Error parsing YAML: ${error}`; - } - } - - const response = await fetch(url, { - method, - headers, - body - }); - - let responseText = await response.text(); - if (options.json) { - try { - responseText = JSON.parse(responseText); - } catch (e) { - responseText = `Error parsing JSON: ${e}`; - } - } - - node.value = content; - node.meta = undefined; - - context.addRequest({ - id: options.id?.toString(), - request: { - method, - url, - headers, - body, - }, - response: { - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries(response.headers.entries()), - body: responseText, - }, - }); - }, - }); - + if (node.type !== 'code' || node.lang !== 'http') { + return; } + const optionParts = node.meta?.split(',') || []; + const options = Object.fromEntries( + optionParts.filter(Boolean).map((option) => { + const [key, value] = option.split('='); + return [key.trim(), value?.trim() || true]; + }) + ); + + addStep({ + type: 'http', + node, + action: async ({ context }) => { + if (options.disable === true) { + return; + } + const template = Handlebars.compile(node.value); + const content = template(context); + const [head, body] = content.split('\n\n'); + const [top, ...headerItems] = head.split('\n'); + const [method, url] = top.split(' '); + + const headers = Object.fromEntries( + headerItems.map((header) => { + const [key, value] = header.split(':'); + return [key.trim(), value?.trim() || '']; + }) + ); + + let parsedBody = body; + if (options.format === 'yaml') { + try { + const parsed = YAML.parse(body); + parsedBody = JSON.stringify(parsed); + } catch (error) { + parsedBody = `Error parsing YAML: ${error}`; + } + } + + const response = await fetch(url, { + method, + headers, + body + }); + + let responseText = await response.text(); + if (options.json) { + try { + responseText = JSON.parse(responseText); + } catch (e) { + responseText = `Error parsing JSON: ${e}`; + } + } + + node.value = content; + node.meta = undefined; + + context.addRequest({ + id: options.id?.toString(), + request: { + method, + url, + headers, + body, + }, + response: { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseText, + }, + }); + }, + }); }; export { httpHandler }; diff --git a/src/execution/handlers/handlers.file.ts b/src/execution/handlers/handlers.md.ts similarity index 100% rename from src/execution/handlers/handlers.file.ts rename to src/execution/handlers/handlers.md.ts diff --git a/src/execution/handlers/handlers.ts b/src/execution/handlers/handlers.ts index 6fe97ca..4c19e9d 100644 --- a/src/execution/handlers/handlers.ts +++ b/src/execution/handlers/handlers.ts @@ -1,10 +1,11 @@ import { ExecutionHandler } from "../execution.js"; -import { fileHandler } from "./handlers.file.js"; +import { fileHandler } from "./handlers.md.js"; import { httpHandler } from "./handlers.http.js"; import { inputHandler } from "./handlers.input.js"; import { rawMdHandler } from "./handlers.raw-md.js"; import { responseHandler } from "./handlers.response.js"; import { textHandler } from "./handlers.text.js"; +import { codeHandler } from "./handlers.code.js"; const handlers = [ fileHandler, @@ -13,6 +14,7 @@ const handlers = [ textHandler, inputHandler, rawMdHandler, + codeHandler, ] satisfies ExecutionHandler[]; export { handlers };