json-rest-api 0.1.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +16 -0
- package/LICENSE +674 -20
- package/README.md +728 -120
- package/RELATIONSHIPS.md +1013 -0
- package/TODO_rest-api-plugin-extras.js +142 -0
- package/connectors_ideas/express-plugin.js +120 -0
- package/connectors_ideas/fastify.js +219 -0
- package/connectors_ideas/http-plugin.js +138 -0
- package/connectors_ideas/koa-plugin.js +113 -0
- package/docs/fileUploads.md +722 -0
- package/docs/mini-guides/file-uploads-complete.md +159 -0
- package/examples/.gitkeep +0 -0
- package/examples/express-example.js +262 -0
- package/examples/express-plugin.js +366 -0
- package/examples/file-upload-example.js +205 -0
- package/examples/filename-strategies.js +140 -0
- package/examples/knex-basic.js +66 -0
- package/index.js +28 -202
- package/lib/payload-validators.js +685 -0
- package/lib/rest-api-errors.js +58 -0
- package/package.json +37 -20
- package/payloadValdators.js +686 -0
- package/plugins/core/connectors/express-plugin.js +449 -0
- package/plugins/core/connectors/http-plugin.js +450 -0
- package/plugins/core/file-handling-plugin.js +338 -0
- package/plugins/core/lib/busboy-detector.js +167 -0
- package/plugins/core/lib/formidable-detector.js +130 -0
- package/plugins/core/rest-api-knex-plugin.js +200 -0
- package/plugins/core/rest-api-plugin.js +1419 -0
- package/plugins/storage/local-storage.js +218 -0
- package/plugins/storage/s3-storage.js +125 -0
- package/test-examples/package-lock.json +906 -0
- package/test-examples/package.json +15 -0
- package/test-examples/test-basic-api.js +11 -0
- package/test-examples/test-books-resource.js +116 -0
- package/test-examples/test-complete-file-upload.js +140 -0
- package/test-examples/test-debug.js +28 -0
- package/test-examples/test-express-plugin.js +177 -0
- package/test-examples/test-express-simple.js +28 -0
- package/test-examples/test-express-with-import.js +71 -0
- package/test-examples/test-file-upload.js +132 -0
- package/test-examples/test-http-plugin.js +159 -0
- package/test-examples/test-programmatic-api.js +188 -0
- package/test-examples/test-rest-api-plugin.js +13 -0
- package/.jshintrc +0 -48
- package/.npmignore +0 -15
- package/examples/client.js +0 -18
- package/examples/client2.js +0 -27
- package/examples/server.js +0 -23
- package/test.js +0 -101
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Fetches the collection of full "child" resource objects belonging to a parent.
|
|
15
|
+
* This function calls the related resource link (e.g., GET /api/articles/1/comments).
|
|
16
|
+
* @param {string} parentType - The type of the parent resource (e.g., "articles").
|
|
17
|
+
* @param {(string|number)} parentId - The ID of the parent resource.
|
|
18
|
+
* @param {string} relationshipName - The name of the relationship to fetch (e.g., "comments").
|
|
19
|
+
* @param {object} [queryParams={}] - Optional. An object to filter, sort, or paginate the **returned child resources**.
|
|
20
|
+
* @returns {Promise<object>} A Promise that resolves to the JSON:API collection response for the related resources.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* // Get the 10 newest comments for article "123", including each comment's author
|
|
24
|
+
* const commentsResponse = await api.getRelated('articles', '123', 'comments', {
|
|
25
|
+
* include: 'author',
|
|
26
|
+
* sort: '-created-at',
|
|
27
|
+
* page: { "number": 1, "size": 10 }
|
|
28
|
+
* });
|
|
29
|
+
* @see GET /api/articles/123/comments?include=author&sort=-created-at&page[number]=1&page[size]=10
|
|
30
|
+
* // Example Return Value for commentsResponse:
|
|
31
|
+
* // {
|
|
32
|
+
* // "links": { "self": "/api/articles/123/comments?..." },
|
|
33
|
+
* // "data": [
|
|
34
|
+
* // { "type": "comments", "id": "456", "attributes": { ... }, "relationships": { ... } }
|
|
35
|
+
* // ],
|
|
36
|
+
* // "included": [{ "type": "people", "id": "9", "attributes": { ... } }]
|
|
37
|
+
* // }
|
|
38
|
+
*/
|
|
39
|
+
async function getRelated(parentType, parentId, relationshipName, queryParams = {}) {
|
|
40
|
+
// Implementation calls GET /api/{parentType}/{parentId}/{relationshipName}
|
|
41
|
+
console.log(`GET /api/${parentType}/${parentId}/${relationshipName}`, { queryParams });
|
|
42
|
+
return Promise.resolve({});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fetches the linkage data only (the `type` and `id` identifiers) for a relationship.
|
|
47
|
+
* This function calls the relationship link (e.g., GET /api/articles/1/relationships/comments).
|
|
48
|
+
* @param {string} parentType - The type of the parent resource (e.g., "articles").
|
|
49
|
+
* @param {(string|number)} parentId - The ID of the parent resource.
|
|
50
|
+
* @param {string} relationshipName - The name of the relationship to fetch (e.g., "comments").
|
|
51
|
+
* @returns {Promise<object>} A Promise that resolves to the JSON:API relationship linkage object.
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* // Get the linkage data for the author of article "123"
|
|
55
|
+
* const authorLinkageResponse = await api.getRelationship('articles', '123', 'author');
|
|
56
|
+
* @see GET /api/articles/123/relationships/author
|
|
57
|
+
* // Example Return Value for authorLinkageResponse:
|
|
58
|
+
* // {
|
|
59
|
+
* // "links": {
|
|
60
|
+
* // "self": "/api/articles/123/relationships/author",
|
|
61
|
+
* // "related": "/api/articles/123/author"
|
|
62
|
+
* // },
|
|
63
|
+
* // "data": { "type": "people", "id": "9" }
|
|
64
|
+
* // }
|
|
65
|
+
*/
|
|
66
|
+
async function getRelationship(parentType, parentId, relationshipName) {
|
|
67
|
+
// Implementation calls GET /api/{parentType}/{parentId}/relationships/{relationshipName}
|
|
68
|
+
console.log(`GET /api/${parentType}/${parentId}/relationships/${relationshipName}`);
|
|
69
|
+
return Promise.resolve({});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Adds one or more links to an existing to-many relationship. This links existing resources; it does not create new ones.
|
|
75
|
+
* This function sends a POST request to the relationship link.
|
|
76
|
+
* @param {string} parentType - The type of the parent resource.
|
|
77
|
+
* @param {(string|number)} parentId - The ID of the parent resource.
|
|
78
|
+
* @param {string} relationshipName - The name of the relationship to modify.
|
|
79
|
+
* @param {object[]} linkage - An array of resource identifier objects ({ type, id }) to add to the relationship.
|
|
80
|
+
* @returns {Promise<void>} A Promise that resolves when the addition is complete. Server returns 204 No Content.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* // Add tags with IDs "5" and "8" to article "123"
|
|
84
|
+
* await api.postRelationship('articles', '123', 'tags', [
|
|
85
|
+
* { "type": "tags", "id": "5" },
|
|
86
|
+
* { "type": "tags", "id": "8" }
|
|
87
|
+
* ]);
|
|
88
|
+
* @see POST /api/articles/123/relationships/tags
|
|
89
|
+
*/
|
|
90
|
+
async function postRelationship(parentType, parentId, relationshipName, linkage) {
|
|
91
|
+
// Implementation calls POST /api/{parentType}/{parentId}/relationships/{relationshipName}
|
|
92
|
+
console.log(`POST /api/${parentType}/${parentId}/relationships/${relationshipName}`, { data: linkage });
|
|
93
|
+
return Promise.resolve();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Completely replaces the links in a relationship with a new set of links.
|
|
98
|
+
* This function sends a PATCH request to the relationship link.
|
|
99
|
+
* @param {string} parentType - The type of the parent resource.
|
|
100
|
+
* @param {(string|number)} parentId - The ID of the parent resource.
|
|
101
|
+
* @param {string} relationshipName - The name of the relationship to modify.
|
|
102
|
+
* @param {object|object[]|null} linkage - The new, complete set of resource identifier objects ({ type, id }) for the relationship.
|
|
103
|
+
* @returns {Promise<void>} A Promise that resolves when the replacement is complete. Server returns 204 No Content.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* // Set the tags for article "123" to be ONLY tag "5"
|
|
107
|
+
* await api.patchRelationship('articles', '123', 'tags', [
|
|
108
|
+
* { "type": "tags", "id": "5" }
|
|
109
|
+
* ]);
|
|
110
|
+
* @see PATCH /api/articles/123/relationships/tags
|
|
111
|
+
*/
|
|
112
|
+
async function patchRelationship(parentType, parentId, relationshipName, linkage) {
|
|
113
|
+
// Implementation calls PATCH /api/{parentType}/{parentId}/relationships/{relationshipName}
|
|
114
|
+
console.log(`PATCH /api/${parentType}/${parentId}/relationships/${relationshipName}`, { data: linkage });
|
|
115
|
+
return Promise.resolve();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Removes one or more links from a to-many relationship. This does not delete the resources themselves.
|
|
120
|
+
* This function sends a DELETE request to the relationship link.
|
|
121
|
+
* @param {string} parentType - The type of the parent resource.
|
|
122
|
+
* @param {(string|number)} parentId - The ID of the parent resource.
|
|
123
|
+
* @param {string} relationshipName - The name of the relationship to modify.
|
|
124
|
+
* @param {object[]} linkage - An array of resource identifier objects ({ type, id }) to remove from the relationship.
|
|
125
|
+
* @returns {Promise<void>} A Promise that resolves when the removal is complete. Server returns 204 No Content.
|
|
126
|
+
*
|
|
127
|
+
* @example
|
|
128
|
+
* // Remove the tag with ID "7" from article "123"
|
|
129
|
+
* await api.deleteRelationship('articles', '123', 'tags', [
|
|
130
|
+
* { "type": "tags", "id": "7" }
|
|
131
|
+
* ]);
|
|
132
|
+
* @see DELETE /api/articles/123/relationships/tags
|
|
133
|
+
*/
|
|
134
|
+
async function deleteRelationship(parentType, parentId, relationshipName, linkage) {
|
|
135
|
+
// Implementation calls DELETE /api/{parentType}/{parentId}/relationships/{relationshipName}
|
|
136
|
+
console.log(`DELETE /api/${parentType}/${parentId}/relationships/${relationshipName}`, { data: linkage });
|
|
137
|
+
return Promise.resolve();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http-plugin-express.js
|
|
3
|
+
* This plugin for Hooked API provides an HTTP layer using the Express.js framework.
|
|
4
|
+
* It automatically generates JSON:API compliant RESTful routes for each scope
|
|
5
|
+
* added to the API instance.
|
|
6
|
+
*/
|
|
7
|
+
import { HookedApiError } from './hooked-api.js';
|
|
8
|
+
import { handleApiError } from './error-handler.js';
|
|
9
|
+
|
|
10
|
+
export const HttpExpressPlugin = {
|
|
11
|
+
name: 'http-express',
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Installs the HTTP plugin, connecting the API to an Express app.
|
|
15
|
+
* @param {object} context - The installation context from Hooked API.
|
|
16
|
+
* @param {object} context.pluginOptions - Options passed during `api.use()`.
|
|
17
|
+
* @param {Express.Application} context.pluginOptions.http.app - The Express app instance. (Required)
|
|
18
|
+
* @param {string} [context.pluginOptions.http.prefix='/api'] - The URL prefix for all API routes.
|
|
19
|
+
* @param {Api} api - The API instance itself.
|
|
20
|
+
*/
|
|
21
|
+
install(context, api) {
|
|
22
|
+
const { pluginOptions, log } = context;
|
|
23
|
+
const httpOptions = pluginOptions.http || {};
|
|
24
|
+
|
|
25
|
+
const { app, prefix = '/api' } = httpOptions;
|
|
26
|
+
|
|
27
|
+
if (!app) {
|
|
28
|
+
throw new HookedApiError(
|
|
29
|
+
"The 'http-express' plugin requires an Express 'app' instance to be passed in the options. Example: api.use(HttpExpressPlugin, { http: { app: myExpressApp } })",
|
|
30
|
+
'CONFIGURATION_ERROR'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
log.info(`Express HTTP plugin installed. Routes will be prefixed with '${prefix}'.`);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Creates a generic Express request handler for a specific API method.
|
|
38
|
+
* This factory function avoids code duplication for route handlers.
|
|
39
|
+
* @param {string} methodName - The name of the scope method to call (e.g., 'get', 'query').
|
|
40
|
+
* @returns {Function} An async Express route handler.
|
|
41
|
+
*/
|
|
42
|
+
const _createRequestHandler = (methodName) => {
|
|
43
|
+
// This is an async function that Express 5 will handle correctly,
|
|
44
|
+
// automatically catching promise rejections and passing them to the error handler.
|
|
45
|
+
return async (req, res, next) => {
|
|
46
|
+
const { scopeName, id } = req.params;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
// 1. Translate Express req into the API method's specific params object.
|
|
50
|
+
const apiParams = {};
|
|
51
|
+
|
|
52
|
+
if (['get', 'put', 'patch', 'delete'].includes(methodName)) {
|
|
53
|
+
apiParams.id = id;
|
|
54
|
+
}
|
|
55
|
+
if (['query', 'get', 'post', 'put', 'patch'].includes(methodName)) {
|
|
56
|
+
// Express automatically parses simple query strings. For nested objects
|
|
57
|
+
// like `fields[articles]`, ensure the 'qs' query parser is enabled in your main app.
|
|
58
|
+
apiParams.queryParams = req.query;
|
|
59
|
+
}
|
|
60
|
+
if (['post', 'put', 'patch'].includes(methodName)) {
|
|
61
|
+
// Assumes `express.json()` body-parser middleware is used on the app.
|
|
62
|
+
apiParams.inputRecord = req.body;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
log.debug(`Express request for ${scopeName}.${methodName}`, { id, query: req.query });
|
|
66
|
+
|
|
67
|
+
// 2. Execute the corresponding API method
|
|
68
|
+
const result = await api.scopes[scopeName][methodName](apiParams);
|
|
69
|
+
|
|
70
|
+
// 3. Send the successful HTTP response
|
|
71
|
+
switch (methodName) {
|
|
72
|
+
case 'post':
|
|
73
|
+
res.status(201).json(result);
|
|
74
|
+
break;
|
|
75
|
+
case 'delete':
|
|
76
|
+
res.status(204).send();
|
|
77
|
+
break;
|
|
78
|
+
default:
|
|
79
|
+
res.status(200).json(result);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// 4. Pass any errors to Express's next-tick error handler.
|
|
83
|
+
next(error);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Creates all the standard RESTful routes for a given scope.
|
|
90
|
+
* @param {string} scopeName - The name of the scope to create routes for.
|
|
91
|
+
*/
|
|
92
|
+
const _createRoutesForScope = (scopeName) => {
|
|
93
|
+
// Use a new router for each scope to keep things clean.
|
|
94
|
+
const router = express.Router();
|
|
95
|
+
const basePath = `/${scopeName}`;
|
|
96
|
+
const singlePath = `${basePath}/:id`;
|
|
97
|
+
|
|
98
|
+
log.info(`Creating Express HTTP routes for scope: '${scopeName}'`);
|
|
99
|
+
|
|
100
|
+
router.get('/', _createRequestHandler('query'));
|
|
101
|
+
router.get('/:id', _createRequestHandler('get'));
|
|
102
|
+
router.post('/', _createRequestHandler('post'));
|
|
103
|
+
router.put('/:id', _createRequestHandler('put'));
|
|
104
|
+
router.patch('/:id', _createRequestHandler('patch'));
|
|
105
|
+
router.delete('/:id', _createRequestHandler('delete'));
|
|
106
|
+
|
|
107
|
+
// Mount the scope's router on the main app at the correct prefix.
|
|
108
|
+
app.use(prefix, router);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// --- Main Logic: Wrap `addScope` to automate route creation ---
|
|
112
|
+
const originalAddScope = api.addScope;
|
|
113
|
+
|
|
114
|
+
api.addScope = (name, options, extras) => {
|
|
115
|
+
const result = originalAddScope.call(api, name, options, extras);
|
|
116
|
+
_createRoutesForScope(name);
|
|
117
|
+
return result;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http-plugin-fastify.js
|
|
3
|
+
* This plugin for Hooked API provides a high-performance HTTP layer using the Fastify framework.
|
|
4
|
+
* It automatically generates JSON:API compliant RESTful routes and schemas for each scope.
|
|
5
|
+
*
|
|
6
|
+
* You'll need to install dependencies: npm install fastify
|
|
7
|
+
*/
|
|
8
|
+
import { HookedApiError } from './hooked-api.js';
|
|
9
|
+
import { handleFastifyApiError } from './error-handler-fastify.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* **UPGRADED:** Automatically generates a comprehensive Fastify schema object that conforms
|
|
13
|
+
* to the full JSON:API specification, including relationships, links, and meta.
|
|
14
|
+
* @param {string} scopeName - The name of the resource type (e.g., 'articles').
|
|
15
|
+
* @param {object} scopeOptions - The full options object for the scope.
|
|
16
|
+
* @returns {object} A complete Fastify schema for all CRUD routes.
|
|
17
|
+
*/
|
|
18
|
+
function _generateFastifySchema(scopeName, scopeOptions = {}) {
|
|
19
|
+
const appSchema = scopeOptions.schema || {};
|
|
20
|
+
const relationshipsSchema = scopeOptions.relationships || {};
|
|
21
|
+
|
|
22
|
+
// --- Reusable Schema Components ---
|
|
23
|
+
const metaObject = { type: 'object', additionalProperties: true };
|
|
24
|
+
const linksObject = {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
self: { type: 'string', format: 'uri-reference' },
|
|
28
|
+
related: { type: 'string', format: 'uri-reference' },
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const resourceIdentifierObject = {
|
|
32
|
+
type: 'object',
|
|
33
|
+
required: ['type', 'id'],
|
|
34
|
+
properties: {
|
|
35
|
+
type: { type: 'string' },
|
|
36
|
+
id: { type: 'string' }
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// --- Build Attribute Properties from simple schema ---
|
|
41
|
+
const attributeProperties = {};
|
|
42
|
+
const requiredAttributes = [];
|
|
43
|
+
for (const [key, value] of Object.entries(appSchema)) {
|
|
44
|
+
let jsonSchemaType = 'string';
|
|
45
|
+
if (value.type === 'number') jsonSchemaType = 'number';
|
|
46
|
+
if (value.type === 'boolean') jsonSchemaType = 'boolean';
|
|
47
|
+
|
|
48
|
+
attributeProperties[key] = { type: jsonSchemaType };
|
|
49
|
+
if (value.format) attributeProperties[key].format = value.format;
|
|
50
|
+
if (value.required) requiredAttributes.push(key);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// --- Build Relationship Properties ---
|
|
54
|
+
const relationshipProperties = {};
|
|
55
|
+
for (const [key, value] of Object.entries(relationshipsSchema)) {
|
|
56
|
+
const isToMany = value.type === 'hasMany';
|
|
57
|
+
relationshipProperties[key] = {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: {
|
|
60
|
+
links: linksObject,
|
|
61
|
+
data: isToMany
|
|
62
|
+
? { type: 'array', items: resourceIdentifierObject }
|
|
63
|
+
: resourceIdentifierObject
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- Define the core Resource Object schema ---
|
|
69
|
+
const resourceObject = {
|
|
70
|
+
type: 'object',
|
|
71
|
+
required: ['type', 'id'],
|
|
72
|
+
properties: {
|
|
73
|
+
id: { type: 'string' },
|
|
74
|
+
type: { type: 'string', const: scopeName },
|
|
75
|
+
attributes: {
|
|
76
|
+
type: 'object',
|
|
77
|
+
properties: attributeProperties
|
|
78
|
+
},
|
|
79
|
+
relationships: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: relationshipProperties
|
|
82
|
+
},
|
|
83
|
+
links: linksObject
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// --- Define the full top-level JSON:API Document ---
|
|
88
|
+
const singleDocument = {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
data: resourceObject,
|
|
92
|
+
included: { type: 'array', items: { type: 'object' } }, // Generic included for simplicity
|
|
93
|
+
links: linksObject,
|
|
94
|
+
meta: metaObject
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const collectionDocument = {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: {
|
|
100
|
+
data: { type: 'array', items: resourceObject },
|
|
101
|
+
included: { type: 'array', items: { type: 'object' } },
|
|
102
|
+
links: { ...linksObject, properties: { ...linksObject.properties, next: { type: 'string' }, prev: { type: 'string' } } },
|
|
103
|
+
meta: metaObject
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
// --- Build the final schema for each route ---
|
|
109
|
+
return {
|
|
110
|
+
post: {
|
|
111
|
+
body: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
required: ['data'],
|
|
114
|
+
properties: {
|
|
115
|
+
data: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
required: ['type', 'attributes'],
|
|
118
|
+
properties: {
|
|
119
|
+
type: { type: 'string', const: scopeName },
|
|
120
|
+
attributes: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
required: requiredAttributes,
|
|
123
|
+
properties: attributeProperties
|
|
124
|
+
},
|
|
125
|
+
relationships: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: relationshipProperties
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
response: { 201: singleDocument }
|
|
134
|
+
},
|
|
135
|
+
get: {
|
|
136
|
+
response: { 200: singleDocument }
|
|
137
|
+
},
|
|
138
|
+
query: {
|
|
139
|
+
response: { 200: collectionDocument }
|
|
140
|
+
},
|
|
141
|
+
patch: {
|
|
142
|
+
body: { /* A more complex partial schema would be needed for full validation */ },
|
|
143
|
+
response: { 200: singleDocument }
|
|
144
|
+
},
|
|
145
|
+
put: {
|
|
146
|
+
body: { /* Similar to POST body */ },
|
|
147
|
+
response: { 200: singleDocument }
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
export const HttpFastifyPlugin = {
|
|
154
|
+
name: 'http-fastify',
|
|
155
|
+
|
|
156
|
+
install(context, api) {
|
|
157
|
+
const { pluginOptions, log } = context;
|
|
158
|
+
const httpOptions = pluginOptions.http || {};
|
|
159
|
+
const { app, prefix = '/api' } = httpOptions;
|
|
160
|
+
|
|
161
|
+
if (!app) {
|
|
162
|
+
throw new HookedApiError(
|
|
163
|
+
"The 'http-fastify' plugin requires a Fastify 'app' instance to be passed in the options. Example: api.use(HttpFastifyPlugin, { http: { app: myFastifyApp } })",
|
|
164
|
+
'CONFIGURATION_ERROR'
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
log.info(`Fastify HTTP plugin installed. Routes will be prefixed with '${prefix}'.`);
|
|
169
|
+
|
|
170
|
+
const _createFastifyHandler = (methodName) => {
|
|
171
|
+
return async (request, reply) => {
|
|
172
|
+
const { scopeName, id } = request.params;
|
|
173
|
+
const apiParams = {
|
|
174
|
+
id,
|
|
175
|
+
inputRecord: request.body,
|
|
176
|
+
queryParams: request.query,
|
|
177
|
+
};
|
|
178
|
+
log.debug(`Fastify request for ${scopeName}.${methodName}`, { id, query: apiParams.queryParams });
|
|
179
|
+
const result = await api.scopes[scopeName][methodName](apiParams);
|
|
180
|
+
switch (methodName) {
|
|
181
|
+
case 'post':
|
|
182
|
+
reply.code(201).send(result);
|
|
183
|
+
break;
|
|
184
|
+
case 'delete':
|
|
185
|
+
reply.code(204).send();
|
|
186
|
+
break;
|
|
187
|
+
default:
|
|
188
|
+
reply.code(200).send(result);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const _createScopePlugin = (scopeName, scopeOptions) => {
|
|
194
|
+
const fastifySchema = scopeOptions.fastifySchema || _generateFastifySchema(scopeName, scopeOptions);
|
|
195
|
+
|
|
196
|
+
return async (fastifyInstance) => {
|
|
197
|
+
log.info(`Creating Fastify HTTP routes for scope: '${scopeName}'`);
|
|
198
|
+
fastifyInstance.get(`/${scopeName}`, { schema: fastifySchema.query }, _createFastifyHandler('query'));
|
|
199
|
+
fastifyInstance.get(`/${scopeName}/:id`, { schema: fastifySchema.get }, _createFastifyHandler('get'));
|
|
200
|
+
fastifyInstance.post(`/${scopeName}`, { schema: fastifySchema.post }, _createFastifyHandler('post'));
|
|
201
|
+
fastifyInstance.put(`/${scopeName}/:id`, { schema: fastifySchema.put }, _createFastifyHandler('put'));
|
|
202
|
+
fastifyInstance.patch(`/${scopeName}/:id`, { schema: fastifySchema.patch }, _createFastifyHandler('patch'));
|
|
203
|
+
fastifyInstance.delete(`/${scopeName}/:id`, { schema: fastifySchema.delete }, _createFastifyHandler('delete'));
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const originalAddScope = api.addScope;
|
|
208
|
+
|
|
209
|
+
api.addScope = (name, options, extras) => {
|
|
210
|
+
const result = originalAddScope.call(api, name, options, extras);
|
|
211
|
+
app.register(_createScopePlugin(name, options), { prefix });
|
|
212
|
+
return result;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
app.setErrorHandler((error, request, reply) => {
|
|
216
|
+
handleFastifyApiError(error, request, reply, log);
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http-plugin.js
|
|
3
|
+
* * This plugin for Hooked API provides a lightweight HTTP layer using Node.js's native `http` module.
|
|
4
|
+
* It creates a server and automatically generates JSON:API compliant RESTful routes for each scope.
|
|
5
|
+
*
|
|
6
|
+
* It uses the 'qs' library to parse complex query strings, just like Express.
|
|
7
|
+
* You'll need to install it: npm install qs
|
|
8
|
+
*/
|
|
9
|
+
import http from 'http';
|
|
10
|
+
import qs from 'qs'; // The same query string parser Express uses
|
|
11
|
+
import { HookedApiError } from './hooked-api.js';
|
|
12
|
+
import { handleApiError } from './error-handler.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A helper function to parse the JSON body from an incoming request.
|
|
16
|
+
* @param {http.IncomingMessage} req - The request object.
|
|
17
|
+
* @returns {Promise<object|null>} A promise that resolves with the parsed JSON object or null.
|
|
18
|
+
*/
|
|
19
|
+
async function parseJsonBody(req) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
if (req.method !== 'POST' && req.method !== 'PUT' && req.method !== 'PATCH') {
|
|
22
|
+
return resolve(null);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let body = '';
|
|
26
|
+
req.on('data', chunk => {
|
|
27
|
+
body += chunk.toString();
|
|
28
|
+
});
|
|
29
|
+
req.on('end', () => {
|
|
30
|
+
try {
|
|
31
|
+
resolve(body ? JSON.parse(body) : null);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
reject(new HookedApiError('Invalid JSON in request body.', 'BAD_REQUEST'));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
req.on('error', reject);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const HttpPlugin = {
|
|
41
|
+
name: 'http',
|
|
42
|
+
|
|
43
|
+
install(context, api) {
|
|
44
|
+
const { pluginOptions, log } = context;
|
|
45
|
+
const httpOptions = pluginOptions.http || {};
|
|
46
|
+
const { port = 3000, prefix = '/api' } = httpOptions;
|
|
47
|
+
|
|
48
|
+
// This map will store our route handlers
|
|
49
|
+
const routes = new Map();
|
|
50
|
+
|
|
51
|
+
// A simple regex to match our API routes
|
|
52
|
+
// It captures the scope name and an optional ID
|
|
53
|
+
const routeRegex = new RegExp(`^${prefix}/([a-zA-Z0-9_-]+)(?:/([a-zA-Z0-9_-]+))?/?$`);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The main request handler for the http.Server.
|
|
57
|
+
* This function acts as our router.
|
|
58
|
+
*/
|
|
59
|
+
const requestListener = async (req, res) => {
|
|
60
|
+
const { url, method } = req;
|
|
61
|
+
const [path, queryString] = url.split('?');
|
|
62
|
+
|
|
63
|
+
const match = path.match(routeRegex);
|
|
64
|
+
|
|
65
|
+
if (!match) {
|
|
66
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
67
|
+
return res.end(JSON.stringify({ errors: [{ status: '404', title: 'Not Found' }] }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const [, scopeName, id] = match;
|
|
71
|
+
|
|
72
|
+
// Determine the API method based on the HTTP verb and presence of an ID
|
|
73
|
+
let apiMethodName;
|
|
74
|
+
if (method === 'GET') {
|
|
75
|
+
apiMethodName = id ? 'get' : 'query';
|
|
76
|
+
} else if (method === 'POST' && !id) {
|
|
77
|
+
apiMethodName = 'post';
|
|
78
|
+
} else if (method === 'PUT' && id) {
|
|
79
|
+
apiMethodName = 'put';
|
|
80
|
+
} else if (method === 'PATCH' && id) {
|
|
81
|
+
apiMethodName = 'patch';
|
|
82
|
+
} else if (method === 'DELETE' && id) {
|
|
83
|
+
apiMethodName = 'delete';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!apiMethodName || !api.scopes[scopeName]) {
|
|
87
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
88
|
+
return res.end(JSON.stringify({ errors: [{ status: '404', title: 'Not Found' }] }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// 1. Translate the native request into API method params
|
|
93
|
+
const apiParams = {};
|
|
94
|
+
|
|
95
|
+
// Use 'qs' to parse the query string into a nested object
|
|
96
|
+
apiParams.queryParams = queryString ? qs.parse(queryString) : {};
|
|
97
|
+
apiParams.inputRecord = await parseJsonBody(req);
|
|
98
|
+
if (id) {
|
|
99
|
+
apiParams.id = id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
log.debug(`HTTP request for ${scopeName}.${apiMethodName}`, { id, query: apiParams.queryParams });
|
|
103
|
+
|
|
104
|
+
// 2. Execute the corresponding API method
|
|
105
|
+
const result = await api.scopes[scopeName][apiMethodName](apiParams);
|
|
106
|
+
|
|
107
|
+
// 3. Send the successful HTTP response
|
|
108
|
+
let statusCode = 200;
|
|
109
|
+
let responseBody = JSON.stringify(result);
|
|
110
|
+
|
|
111
|
+
if (apiMethodName === 'post') {
|
|
112
|
+
statusCode = 201;
|
|
113
|
+
} else if (apiMethodName === 'delete') {
|
|
114
|
+
statusCode = 204;
|
|
115
|
+
responseBody = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
res.setHeader('Content-Type', 'application/json');
|
|
119
|
+
res.writeHead(statusCode);
|
|
120
|
+
res.end(responseBody);
|
|
121
|
+
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// 4. Handle any errors thrown by the API
|
|
124
|
+
// Note: The error handler now needs the `res` object directly
|
|
125
|
+
handleApiError(error, res, log);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Create and start the HTTP server
|
|
130
|
+
const server = http.createServer(requestListener);
|
|
131
|
+
server.listen(port, () => {
|
|
132
|
+
log.info(`HTTP server started on http://localhost:${port}`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// We no longer need to wrap addScope, as the router is dynamic.
|
|
136
|
+
// The plugin's job is simply to start the server.
|
|
137
|
+
}
|
|
138
|
+
};
|