4 Commits
0.1.2 ... 0.1.6

Author SHA1 Message Date
Morten Olsen
2a2f2f5627 feat: load env vars 2025-05-18 20:34:51 +02:00
Morten Olsen
0eff8cf603 feat: auto set env to host envs 2025-05-18 20:34:11 +02:00
Morten Olsen
1b2d345420 feat: support template in all code blocks 2025-05-18 20:31:54 +02:00
Morten Olsen
1cb885bb32 feat: improved input format 2025-05-18 20:26:15 +02:00
11 changed files with 183 additions and 93 deletions

View File

@@ -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:

View File

@@ -1,3 +1,4 @@
#!/usr/bin/env node
import 'dotenv/config.js';
import '../dist/cli/cli.js';

View File

@@ -338,6 +338,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:

View File

@@ -36,6 +36,7 @@
"dependencies": {
"blessed": "^0.1.81",
"commander": "^14.0.0",
"dotenv": "^16.5.0",
"eventemitter3": "^5.0.1",
"handlebars": "^4.7.8",
"hastscript": "^9.0.1",

9
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
commander:
specifier: ^14.0.0
version: 14.0.0
dotenv:
specifier: ^16.5.0
version: 16.5.0
eventemitter3:
specifier: ^5.0.1
version: 5.0.1
@@ -657,6 +660,10 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dotenv@16.5.0:
resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
engines: {node: '>=12'}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -2077,6 +2084,8 @@ snapshots:
dependencies:
dequal: 2.0.3
dotenv@16.5.0: {}
emoji-regex@8.0.0: {}
emojilib@2.4.0: {}

View File

@@ -36,7 +36,7 @@ class Context {
constructor(options: ContextOptions = {}) {
this.input = options.input || {};
this.env = options.env || {};
this.env = options.env || process.env;
this.requests = options.requests || {};
this.responses = options.responses || {};
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -22,10 +22,34 @@ const inputHandler: ExecutionHandler = ({
context.input[name] = node.attributes.default;
}
if (node.attributes?.format === 'number' && context.input[name] !== undefined) {
context.input[name] = Number(context.input[name]);
if (context.input[name] !== undefined && isNaN(Number(context.input[name]))) {
throw new Error(`Input "${name}" must be a number, but got "${context.input[name]}"`);
if (node.attributes?.format && context.input[name] !== undefined) {
const format = node.attributes.format;
if (format === 'number') {
context.input[name] = Number(context.input[name]);
if (context.input[name] !== undefined && isNaN(Number(context.input[name]))) {
throw new Error(`Input "${name}" must be a number, but got "${context.input[name]}"`);
}
}
if (format === 'boolean') {
context.input[name] = context.input[name] === 'true';
}
if (format === 'string') {
context.input[name] = String(context.input[name]);
}
if (format === 'json') {
try {
context.input[name] = JSON.parse(String(context.input[name]));
} catch (error) {
throw new Error(`Input "${name}" must be a valid JSON, but got "${context.input[name]}"`);
}
}
if (format === 'date') {
const date = new Date(context.input[name] as string);
if (isNaN(date.getTime())) {
throw new Error(`Input "${name}" must be a valid date, but got "${context.input[name]}"`);
}
context.input[name] = date;
}
}

View File

@@ -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 };