Skip to content

Commit 3ad6e6b

Browse files
authored
chore: add CLI for raw Todoist API requests via .env (#477)
1 parent d3ac047 commit 3ad6e6b

File tree

6 files changed

+300
-0
lines changed

6 files changed

+300
-0
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copy this file to `.env` and set a valid Todoist API token.
2+
TODOIST_API_TOKEN=todoist_token_here
3+
4+
# Optional base URL override (useful for staging/proxy testing).
5+
# TODOIST_API_BASE_URL=https://api.todoist.com

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ node_modules/
44
.npm
55
.eslintcache
66
scratch.ts
7+
.env
8+
.env.local
79

810
.vscode/
911
.DS_Store

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,30 @@ api.getProjects()
135135
.catch((error) => console.error(error))
136136
```
137137

138+
### Local API Requests With .env
139+
140+
For live API verification, you can run raw requests with a local token:
141+
142+
1. Copy `.env.example` to `.env`.
143+
2. Set `TODOIST_API_TOKEN` in `.env`.
144+
3. Run requests with `npm run api:request -- ...`.
145+
4. Optional: set `TODOIST_API_BASE_URL` in `.env` (defaults to `https://api.todoist.com`).
146+
147+
Examples:
148+
149+
```bash
150+
npm run api:request -- --path /api/v1/tasks
151+
npm run api:request -- --method POST --path /api/v1/tasks --body '{"content":"API smoke test"}'
152+
npm run api:request -- --method POST --path /api/v1/tasks/123 --body '{"due_string":"no date"}'
153+
npm run api:request -- --path /api/v1/tasks --query '{"project_id":"123","limit":10}'
154+
```
155+
156+
To see all options:
157+
158+
```bash
159+
npm run api:request -- --help
160+
```
161+
138162
## Releases
139163

140164
This project uses [Release Please](https://github.com/googleapis/release-please) to automate releases. Releases are created automatically based on [Conventional Commits](https://www.conventionalcommits.org/).

package-lock.json

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"ts-compile-check": "npx tsc -p tsconfig.typecheck.json",
3131
"audit": "npm audit --audit-level=moderate",
3232
"test": "jest",
33+
"api:request": "node ./scripts/todoist-api-request.cjs",
3334
"build:cjs": "npx tsc -p tsconfig.cjs.json",
3435
"build:esm": "npx tsc -p tsconfig.esm.json",
3536
"build:fix-esm": "node scripts/fix-esm-imports.cjs",
@@ -56,6 +57,7 @@
5657
"@types/jest": "30.0.0",
5758
"@typescript-eslint/eslint-plugin": "8.46.3",
5859
"@typescript-eslint/parser": "8.46.3",
60+
"dotenv": "17.3.1",
5961
"eslint": "8.57.1",
6062
"eslint-config-prettier": "8.7.0",
6163
"eslint-import-resolver-webpack": "0.13.2",

scripts/todoist-api-request.cjs

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#!/usr/bin/env node
2+
3+
const { config: dotenvConfig } = require('dotenv');
4+
5+
function printHelp() {
6+
console.log(`
7+
Usage:
8+
npm run api:request -- --method <METHOD> --path <PATH> [options]
9+
10+
Required:
11+
--path <PATH> API path (e.g. /api/v1/tasks) or full URL
12+
13+
Options:
14+
--method, -X <METHOD> HTTP method (default: GET)
15+
--query <JSON|querystring> Query params as JSON object or query string
16+
--body, --data, -d <JSON> JSON payload body
17+
--header, -H "K: V" Additional header (repeatable)
18+
--raw Print response body as plain text
19+
--help, -h Show this message
20+
21+
Auth:
22+
Reads TODOIST_API_TOKEN from .env and sends Authorization: Bearer <token>.
23+
You may also pass your own Authorization header via --header.
24+
25+
Base URL:
26+
Uses TODOIST_API_BASE_URL from env file, or defaults to https://api.todoist.com.
27+
28+
Examples:
29+
npm run api:request -- --path /api/v1/tasks
30+
npm run api:request -- --method POST --path /api/v1/tasks --body '{"content":"API smoke test"}'
31+
npm run api:request -- --method POST --path /api/v1/tasks/123 --body '{"due_string":"no date"}'
32+
npm run api:request -- --path /api/v1/tasks --query '{"project_id":"123","limit":10}'
33+
`);
34+
}
35+
36+
function requireValue(flag, value) {
37+
if (value === undefined) {
38+
throw new Error(`Missing value for ${flag}.`);
39+
}
40+
return value;
41+
}
42+
43+
function parseArgs(argv) {
44+
const args = {
45+
method: 'GET',
46+
path: undefined,
47+
query: undefined,
48+
body: undefined,
49+
headers: {},
50+
raw: false,
51+
help: false,
52+
};
53+
54+
for (let index = 0; index < argv.length; index += 1) {
55+
const token = argv[index];
56+
57+
if (token === '--help' || token === '-h') {
58+
args.help = true;
59+
continue;
60+
}
61+
62+
if (token === '--method' || token === '-X') {
63+
args.method = requireValue(token, argv[index + 1]).toUpperCase();
64+
index += 1;
65+
continue;
66+
}
67+
68+
if (token === '--path') {
69+
args.path = requireValue(token, argv[index + 1]);
70+
index += 1;
71+
continue;
72+
}
73+
74+
if (token === '--query') {
75+
args.query = requireValue(token, argv[index + 1]);
76+
index += 1;
77+
continue;
78+
}
79+
80+
if (token === '--body' || token === '--data' || token === '-d') {
81+
args.body = requireValue(token, argv[index + 1]);
82+
index += 1;
83+
continue;
84+
}
85+
86+
if (token === '--header' || token === '-H') {
87+
const headerValue = argv[index + 1];
88+
if (headerValue === undefined) {
89+
throw new Error('Missing value for --header. Use "Key: Value".');
90+
}
91+
if (!headerValue.includes(':')) {
92+
throw new Error(`Invalid header format "${headerValue}". Use "Key: Value".`);
93+
}
94+
const separatorIndex = headerValue.indexOf(':');
95+
const key = headerValue.slice(0, separatorIndex).trim();
96+
const value = headerValue.slice(separatorIndex + 1).trim();
97+
args.headers[key] = value;
98+
index += 1;
99+
continue;
100+
}
101+
102+
if (token === '--raw') {
103+
args.raw = true;
104+
continue;
105+
}
106+
107+
if (token.startsWith('--')) {
108+
throw new Error(`Unknown option "${token}".`);
109+
}
110+
111+
if (!args.path) {
112+
args.path = token;
113+
continue;
114+
}
115+
116+
throw new Error(`Unexpected positional argument "${token}".`);
117+
}
118+
119+
return args;
120+
}
121+
122+
123+
function parseJson(label, value) {
124+
try {
125+
return JSON.parse(value);
126+
} catch (error) {
127+
throw new Error(`Invalid ${label} JSON: ${(error && error.message) || String(error)}`);
128+
}
129+
}
130+
131+
function appendQueryObject(url, queryObject) {
132+
if (Array.isArray(queryObject) || typeof queryObject !== 'object' || queryObject === null) {
133+
throw new Error('--query JSON must be an object.');
134+
}
135+
136+
for (const [key, value] of Object.entries(queryObject)) {
137+
if (value === undefined || value === null) {
138+
continue;
139+
}
140+
if (Array.isArray(value) || typeof value === 'object') {
141+
url.searchParams.set(key, JSON.stringify(value));
142+
continue;
143+
}
144+
url.searchParams.set(key, String(value));
145+
}
146+
}
147+
148+
function buildUrl(args) {
149+
const baseUrl = process.env.TODOIST_API_BASE_URL || 'https://api.todoist.com';
150+
const isAbsolute = /^https?:\/\//u.test(args.path);
151+
const url = new URL(args.path, isAbsolute ? undefined : baseUrl);
152+
153+
if (!args.query) {
154+
return url;
155+
}
156+
157+
const queryText = args.query.trim();
158+
if (queryText.startsWith('{')) {
159+
appendQueryObject(url, parseJson('query', queryText));
160+
} else {
161+
const extraParams = new URLSearchParams(queryText);
162+
for (const [key, value] of extraParams.entries()) {
163+
url.searchParams.append(key, value);
164+
}
165+
}
166+
167+
return url;
168+
}
169+
170+
function hasAuthorizationHeader(headers) {
171+
return Object.keys(headers).some((key) => key.toLowerCase() === 'authorization');
172+
}
173+
174+
async function main() {
175+
const args = parseArgs(process.argv.slice(2));
176+
if (args.help) {
177+
printHelp();
178+
return;
179+
}
180+
181+
if (!args.path) {
182+
throw new Error('Missing required --path.');
183+
}
184+
185+
dotenvConfig({ quiet: true });
186+
187+
const headers = { ...args.headers };
188+
if (!hasAuthorizationHeader(headers)) {
189+
const token = process.env.TODOIST_API_TOKEN;
190+
if (!token) {
191+
throw new Error(
192+
'Missing TODOIST_API_TOKEN. Add it to .env or pass Authorization header via --header.',
193+
);
194+
}
195+
headers.Authorization = `Bearer ${token}`;
196+
}
197+
198+
let bodyText;
199+
if (args.body !== undefined) {
200+
parseJson('body', args.body);
201+
bodyText = args.body;
202+
if (!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')) {
203+
headers['Content-Type'] = 'application/json';
204+
}
205+
}
206+
207+
const url = buildUrl(args);
208+
const response = await fetch(url, {
209+
method: args.method,
210+
headers,
211+
body: bodyText,
212+
});
213+
214+
const responseText = await response.text();
215+
216+
console.error(`${response.status} ${response.statusText}`);
217+
218+
if (args.raw) {
219+
process.stdout.write(responseText + '\n');
220+
} else {
221+
let parsed;
222+
try {
223+
parsed = responseText ? JSON.parse(responseText) : null;
224+
} catch {
225+
parsed = responseText;
226+
}
227+
228+
const output = {
229+
status: response.status,
230+
statusText: response.statusText,
231+
url: response.url,
232+
data: parsed,
233+
};
234+
235+
process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
236+
}
237+
238+
if (!response.ok) {
239+
process.exitCode = 1;
240+
}
241+
}
242+
243+
main().catch((error) => {
244+
console.error(error.message || String(error));
245+
printHelp();
246+
process.exitCode = 1;
247+
});

0 commit comments

Comments
 (0)