Skip to content

Commit 25c4351

Browse files
committed
feat: implement native Node.js REST API server
- Add lightweight HTTP API using only Node.js built-in modules - Implement endpoints: /health, /api/move, /api/move-batch, /api/validate - Add comprehensive error handling and CORS support - Use proper type guards instead of type assertions - Support environment-based port configuration
1 parent d684db3 commit 25c4351

File tree

1 file changed

+347
-0
lines changed

1 file changed

+347
-0
lines changed

src/api-server.ts

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
/**
2+
* Native Node.js REST API server for markmv
3+
*
4+
* Provides a lightweight HTTP API using only Node.js built-in modules. Exposes markmv functionality
5+
* via RESTful endpoints for language-agnostic access.
6+
*/
7+
8+
import http from 'node:http';
9+
import url from 'node:url';
10+
import { createMarkMv } from './index.js';
11+
import type { OperationResult } from './types/operations.js';
12+
import type { ApiResponse, HealthResponse, ErrorResponse } from './types/api.js';
13+
14+
const markmv = createMarkMv();
15+
const startTime = Date.now();
16+
17+
/** Type guard to check if an object is a valid OperationResult */
18+
function isOperationResult(obj: unknown): obj is OperationResult {
19+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
20+
return false;
21+
}
22+
23+
const record = obj;
24+
return (
25+
'success' in record &&
26+
typeof record.success === 'boolean' &&
27+
'modifiedFiles' in record &&
28+
Array.isArray(record.modifiedFiles) &&
29+
'createdFiles' in record &&
30+
Array.isArray(record.createdFiles) &&
31+
'deletedFiles' in record &&
32+
Array.isArray(record.deletedFiles) &&
33+
'errors' in record &&
34+
Array.isArray(record.errors) &&
35+
'warnings' in record &&
36+
Array.isArray(record.warnings) &&
37+
'changes' in record &&
38+
Array.isArray(record.changes)
39+
);
40+
}
41+
42+
/** Parse JSON request body from HTTP request */
43+
function parseRequestBody(request: http.IncomingMessage): Promise<unknown> {
44+
return new Promise((resolve, reject) => {
45+
const body: Buffer[] = [];
46+
request
47+
.on('data', (chunk: Buffer) => body.push(chunk))
48+
.on('end', () => {
49+
try {
50+
const bodyString = Buffer.concat(body).toString();
51+
resolve(bodyString ? JSON.parse(bodyString) : {});
52+
} catch (error) {
53+
reject(error);
54+
}
55+
})
56+
.on('error', reject);
57+
});
58+
}
59+
60+
/** Send JSON response with proper headers */
61+
function sendJSON(response: http.ServerResponse, statusCode: number, data: unknown): void {
62+
response.writeHead(statusCode, {
63+
'Content-Type': 'application/json',
64+
'Access-Control-Allow-Origin': '*',
65+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
66+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
67+
});
68+
response.end(JSON.stringify(data));
69+
}
70+
71+
/** Create a standardized API response */
72+
function createApiResponse<T>(
73+
success: boolean,
74+
data?: T,
75+
error?: string,
76+
details?: string[]
77+
): ApiResponse<T> {
78+
const response: ApiResponse<T> = {
79+
success,
80+
timestamp: new Date().toISOString(),
81+
};
82+
83+
if (data !== undefined) {
84+
response.data = data;
85+
}
86+
if (error !== undefined) {
87+
response.error = error;
88+
}
89+
if (details !== undefined) {
90+
response.details = details;
91+
}
92+
93+
return response;
94+
}
95+
96+
/** Create an error response */
97+
function createErrorResponse(
98+
statusCode: number,
99+
error: string,
100+
message: string,
101+
details?: string[]
102+
): ErrorResponse {
103+
const response: ErrorResponse = {
104+
error,
105+
message,
106+
statusCode,
107+
};
108+
109+
if (details !== undefined) {
110+
response.details = details;
111+
}
112+
113+
return response;
114+
}
115+
116+
/** Handle CORS preflight requests */
117+
function handleCORS(request: http.IncomingMessage, response: http.ServerResponse): boolean {
118+
if (request.method === 'OPTIONS') {
119+
response.writeHead(200, {
120+
'Access-Control-Allow-Origin': '*',
121+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
122+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
123+
});
124+
response.end();
125+
return true;
126+
}
127+
return false;
128+
}
129+
130+
/** Handle health check endpoint */
131+
async function handleHealth(response: http.ServerResponse): Promise<void> {
132+
const healthResponse: HealthResponse = {
133+
status: 'ok',
134+
version: '1.0.0',
135+
uptime: Date.now() - startTime,
136+
info: {
137+
service: 'markmv-api',
138+
description: 'Markdown file operations API',
139+
},
140+
};
141+
142+
const apiResponse = createApiResponse(true, healthResponse);
143+
sendJSON(response, 200, apiResponse);
144+
}
145+
146+
/** Handle move file endpoint */
147+
async function handleMoveFile(
148+
request: http.IncomingMessage,
149+
response: http.ServerResponse
150+
): Promise<void> {
151+
try {
152+
const body = await parseRequestBody(request);
153+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
154+
const errorResponse = createErrorResponse(400, 'BadRequest', 'Invalid request body');
155+
sendJSON(response, 400, errorResponse);
156+
return;
157+
}
158+
const source = 'source' in body ? body.source : undefined;
159+
const destination = 'destination' in body ? body.destination : undefined;
160+
const options = 'options' in body && body.options ? body.options : {};
161+
162+
if (!source || !destination || typeof source !== 'string' || typeof destination !== 'string') {
163+
const errorResponse = createErrorResponse(
164+
400,
165+
'BadRequest',
166+
'Source and destination are required and must be strings',
167+
['Missing or invalid required fields: source, destination']
168+
);
169+
sendJSON(response, 400, errorResponse);
170+
return;
171+
}
172+
173+
const validOptions = typeof options === 'object' && !Array.isArray(options) ? options : {};
174+
const result = await markmv.moveFile(source, destination, validOptions);
175+
const apiResponse = createApiResponse(true, result);
176+
sendJSON(response, 200, apiResponse);
177+
} catch (error) {
178+
const errorResponse = createErrorResponse(
179+
500,
180+
'InternalServerError',
181+
error instanceof Error ? error.message : 'Unknown error occurred'
182+
);
183+
sendJSON(response, 500, errorResponse);
184+
}
185+
}
186+
187+
/** Handle move files endpoint */
188+
async function handleMoveFiles(
189+
request: http.IncomingMessage,
190+
response: http.ServerResponse
191+
): Promise<void> {
192+
try {
193+
const body = await parseRequestBody(request);
194+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
195+
const errorResponse = createErrorResponse(400, 'BadRequest', 'Invalid request body');
196+
sendJSON(response, 400, errorResponse);
197+
return;
198+
}
199+
const moves = 'moves' in body ? body.moves : undefined;
200+
const options = 'options' in body && body.options ? body.options : {};
201+
202+
if (!moves || !Array.isArray(moves) || moves.length === 0) {
203+
const errorResponse = createErrorResponse(
204+
400,
205+
'BadRequest',
206+
'Moves array is required and must not be empty',
207+
['moves must be a non-empty array of {source, destination} objects']
208+
);
209+
sendJSON(response, 400, errorResponse);
210+
return;
211+
}
212+
213+
const validOptions = typeof options === 'object' && !Array.isArray(options) ? options : {};
214+
const result = await markmv.moveFiles(moves, validOptions);
215+
const apiResponse = createApiResponse(true, result);
216+
sendJSON(response, 200, apiResponse);
217+
} catch (error) {
218+
const errorResponse = createErrorResponse(
219+
500,
220+
'InternalServerError',
221+
error instanceof Error ? error.message : 'Unknown error occurred'
222+
);
223+
sendJSON(response, 500, errorResponse);
224+
}
225+
}
226+
227+
/** Handle validate operation endpoint */
228+
async function handleValidateOperation(
229+
request: http.IncomingMessage,
230+
response: http.ServerResponse
231+
): Promise<void> {
232+
try {
233+
const body = await parseRequestBody(request);
234+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
235+
const errorResponse = createErrorResponse(400, 'BadRequest', 'Invalid request body');
236+
sendJSON(response, 400, errorResponse);
237+
return;
238+
}
239+
const operationResult = 'result' in body ? body.result : undefined;
240+
241+
if (!operationResult) {
242+
const errorResponse = createErrorResponse(400, 'BadRequest', 'Operation result is required', [
243+
'Missing required field: result',
244+
]);
245+
sendJSON(response, 400, errorResponse);
246+
return;
247+
}
248+
249+
if (!isOperationResult(operationResult)) {
250+
const errorResponse = createErrorResponse(
251+
400,
252+
'BadRequest',
253+
'Invalid operation result format'
254+
);
255+
sendJSON(response, 400, errorResponse);
256+
return;
257+
}
258+
259+
const validation = await markmv.validateOperation(operationResult);
260+
const apiResponse = createApiResponse(true, validation);
261+
sendJSON(response, 200, apiResponse);
262+
} catch (error) {
263+
const errorResponse = createErrorResponse(
264+
500,
265+
'InternalServerError',
266+
error instanceof Error ? error.message : 'Unknown error occurred'
267+
);
268+
sendJSON(response, 500, errorResponse);
269+
}
270+
}
271+
272+
/** Main request handler */
273+
async function handleRequest(
274+
request: http.IncomingMessage,
275+
response: http.ServerResponse
276+
): Promise<void> {
277+
// Handle CORS preflight
278+
if (handleCORS(request, response)) {
279+
return;
280+
}
281+
282+
const { method, url: reqUrl } = request;
283+
const parsedUrl = url.parse(reqUrl || '', true);
284+
const path = parsedUrl.pathname;
285+
286+
try {
287+
// Route requests
288+
switch (`${method}:${path}`) {
289+
case 'GET:/health':
290+
await handleHealth(response);
291+
break;
292+
case 'POST:/api/move':
293+
await handleMoveFile(request, response);
294+
break;
295+
case 'POST:/api/move-batch':
296+
await handleMoveFiles(request, response);
297+
break;
298+
case 'POST:/api/validate':
299+
await handleValidateOperation(request, response);
300+
break;
301+
default: {
302+
const errorResponse = createErrorResponse(
303+
404,
304+
'NotFound',
305+
`Route ${method} ${path} not found`,
306+
[
307+
`Available routes: GET /health, POST /api/move, POST /api/move-batch, POST /api/validate`,
308+
]
309+
);
310+
sendJSON(response, 404, errorResponse);
311+
break;
312+
}
313+
}
314+
} catch (error) {
315+
console.error('Unhandled error:', error);
316+
const errorResponse = createErrorResponse(
317+
500,
318+
'InternalServerError',
319+
'An unexpected error occurred'
320+
);
321+
sendJSON(response, 500, errorResponse);
322+
}
323+
}
324+
325+
/** Create and start the HTTP server */
326+
export function createApiServer(port: number = 3000): http.Server {
327+
const server = http.createServer(handleRequest);
328+
329+
server.listen(port, () => {
330+
console.log(`markmv API server running on port ${port}`);
331+
console.log(`Health check: http://localhost:${port}/health`);
332+
console.log(`API endpoints: http://localhost:${port}/api/*`);
333+
});
334+
335+
return server;
336+
}
337+
338+
/** Start the API server with environment-based configuration */
339+
export function startApiServer(): http.Server {
340+
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
341+
return createApiServer(port);
342+
}
343+
344+
// For direct execution
345+
if (process.argv[1] && process.argv[1].endsWith('api-server.js')) {
346+
startApiServer();
347+
}

0 commit comments

Comments
 (0)