@keshavsoft/api-tree 1.0.0 → 2.0.0
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/README.md +158 -51
- package/docs/index.html +298 -26
- package/package.json +2 -2
- package/src/index.d.ts +69 -19
- package/src/index.js +2 -2
- package/src/v2/blueprint/api.json +4 -0
- package/src/v2/blueprint/source.json +22 -0
- package/src/v2/index.js +33 -0
- package/src/v2/internal-working/route/attachPath.js +38 -0
- package/src/v2/internal-working/route/createLeafHandler.js +25 -0
- package/src/v2/internal-working/route/index.js +35 -0
- package/src/v2/internal-working/route/resolveLeafSpec.js +22 -0
- package/src/v2/internal-working/validate/index.js +42 -0
- package/src/v2/internal-working/validate/isPlainObject.js +12 -0
package/README.md
CHANGED
|
@@ -1,93 +1,200 @@
|
|
|
1
1
|
# @keshavsoft/api-tree
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@keshavsoft/api-tree)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[]()
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
> A declarative, ultra-lean routing engine that transforms **Source Schemas**, **API Paths**, and an **Executor** into a callable runtime API tree.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 💡 The Problem & Philosophy
|
|
12
|
+
|
|
13
|
+
In modern modular architectures, APIs often end up bloated because **schema contracts**, **route definitions**, and **runtime execution** are tangled together.
|
|
14
|
+
|
|
15
|
+
`@keshavsoft/api-tree` decouples these concerns completely:
|
|
16
|
+
|
|
17
|
+
$$\mathbf{Runtime\ Tree} = \underbrace{\mathbf{Source\ Schema}}_{\text{JSON Contract}} \;+\; \underbrace{\mathbf{API\ Paths}}_{\text{Dotted Routes}} \;+\; \underbrace{\mathbf{Executor}}_{\text{Execution Flavor}}$$
|
|
18
|
+
|
|
19
|
+
- **Domain-Agnostic**: Does not know about HTTP, XML, TDL, databases, or specific business logic.
|
|
20
|
+
- **Single Responsibility**: Generates the navigable runtime object tree from contracts and leaves execution to your handler.
|
|
21
|
+
- **Zero Dependencies**: Pure, modern ES module running at native speeds.
|
|
6
22
|
|
|
7
23
|
```text
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
24
|
+
┌─────────────────────────┐ ┌────────────────────────┐ ┌───────────────────────┐
|
|
25
|
+
│ Source JSON │ + │ API Paths │ + │ Executor │
|
|
26
|
+
│ (Schema Specifications) │ │ (Dotted Route Strings) │ │ (Custom Handler Func) │
|
|
27
|
+
└───────────┬─────────────┘ └───────────┬────────────┘ └──────────┬────────────┘
|
|
28
|
+
│ │ │
|
|
29
|
+
└───────────────────────┬───────┴─────────────────────────────┘
|
|
30
|
+
│
|
|
31
|
+
▼
|
|
32
|
+
@keshavsoft/api-tree
|
|
33
|
+
│
|
|
34
|
+
▼
|
|
35
|
+
Callable Runtime API Tree
|
|
36
|
+
app.users.profile.fetch("123")
|
|
17
37
|
```
|
|
18
38
|
|
|
19
|
-
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 📦 Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install @keshavsoft/api-tree
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 🚀 Quick Start
|
|
20
50
|
|
|
21
|
-
```
|
|
51
|
+
```javascript
|
|
22
52
|
import apiTree from "@keshavsoft/api-tree";
|
|
23
53
|
|
|
54
|
+
// 1. Source JSON (the schema/metadata contract)
|
|
24
55
|
const source = {
|
|
25
56
|
app: {
|
|
26
57
|
users: {
|
|
27
58
|
profile: {
|
|
28
|
-
fetch: { action: "fetch", resource: "
|
|
59
|
+
fetch: { action: "fetch", resource: "User", timeout: 5000 }
|
|
29
60
|
}
|
|
30
61
|
}
|
|
31
62
|
}
|
|
32
63
|
};
|
|
33
64
|
|
|
65
|
+
// 2. Allowable API Paths
|
|
34
66
|
const apiPaths = [
|
|
35
67
|
"app.users.profile.fetch"
|
|
36
68
|
];
|
|
37
69
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
};
|
|
70
|
+
// 3. Executor Function
|
|
71
|
+
const executor = async ({ inRoutePath, inParam, inLeafSpec }) => {
|
|
72
|
+
console.log(`Executing ${inRoutePath} for ID: ${inParam}`);
|
|
73
|
+
console.log("Leaf schema definition:", inLeafSpec);
|
|
74
|
+
return { id: inParam, name: "Alice", action: inLeafSpec.action };
|
|
44
75
|
};
|
|
45
76
|
|
|
77
|
+
// 4. Build the callable tree
|
|
46
78
|
const api = apiTree(source, apiPaths, executor);
|
|
47
79
|
|
|
48
|
-
|
|
80
|
+
// 5. Call your generated tree!
|
|
81
|
+
const user = await api.users.profile.fetch("123");
|
|
82
|
+
console.log(user);
|
|
83
|
+
// => { id: "123", name: "Alice", action: "fetch" }
|
|
49
84
|
```
|
|
50
85
|
|
|
51
|
-
|
|
86
|
+
---
|
|
52
87
|
|
|
53
|
-
|
|
88
|
+
## ⚡ Key Features (v2)
|
|
54
89
|
|
|
55
|
-
1.
|
|
56
|
-
|
|
57
|
-
3. **executor** — the function that decides what the selected operation actually does.
|
|
90
|
+
### 1. Pre-Resolved Leaf Specification (`inLeafSpec`)
|
|
91
|
+
Your executor automatically receives the pre-resolved definition object directly from `source.json` under `inLeafSpec`. No need to write repetitive nested property access code!
|
|
58
92
|
|
|
59
|
-
|
|
93
|
+
### 2. Intelligent Root Handling
|
|
94
|
+
- **Single-Root Unwrapping**: When all paths share a common root namespace (e.g., `app.users.list`, `app.orders.create`), `api-tree` automatically unwraps the root so you call `api.users.list()` directly.
|
|
95
|
+
- **Multi-Root Preservation**: When paths span multiple top-level domains (e.g., `users.list` and `orders.create`), `api-tree` automatically preserves all top-level roots (`api.users.list()` and `api.orders.create()`).
|
|
96
|
+
- **Explicit Override**: You can pass `{ inUnwrapRoot: false }` to keep the root prefix intact.
|
|
60
97
|
|
|
61
|
-
|
|
98
|
+
### 3. Dual Signature Support
|
|
99
|
+
Supports both **positional** arguments and the **in-local named object** convention:
|
|
62
100
|
|
|
63
|
-
|
|
101
|
+
```javascript
|
|
102
|
+
// Positional
|
|
103
|
+
const api = apiTree(source, apiPaths, executor, options);
|
|
64
104
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
105
|
+
// Named Object
|
|
106
|
+
const api = apiTree({
|
|
107
|
+
inSource: source,
|
|
108
|
+
inApiPaths: apiPaths,
|
|
109
|
+
inExecutor: executor,
|
|
110
|
+
inOptions: { inUnwrapRoot: false }
|
|
111
|
+
});
|
|
112
|
+
```
|
|
69
113
|
|
|
70
|
-
|
|
114
|
+
### 4. Callable Hybrid Branches
|
|
115
|
+
If a path is both a callable node and has child branches (e.g. `api.users` and `api.users.profile`), `api-tree` attaches child branches directly onto the function:
|
|
116
|
+
```javascript
|
|
117
|
+
await api.users(); // Callable root!
|
|
118
|
+
await api.users.profile(); // Child leaf also callable!
|
|
119
|
+
```
|
|
71
120
|
|
|
72
|
-
|
|
121
|
+
---
|
|
73
122
|
|
|
74
|
-
|
|
123
|
+
## 📖 Execution Context Reference
|
|
75
124
|
|
|
76
|
-
|
|
125
|
+
Whenever an attached leaf function is invoked:
|
|
126
|
+
```javascript
|
|
127
|
+
await api.users.profile.fetch("param1", "extraArg1", "extraArg2");
|
|
128
|
+
```
|
|
77
129
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
130
|
+
Your `executor` receives a single, standardized context object:
|
|
131
|
+
|
|
132
|
+
| Property | Type | Description |
|
|
133
|
+
| :--- | :--- | :--- |
|
|
134
|
+
| `inRoutePath` | `string` | The full dot-notation route path (e.g. `"app.users.profile.fetch"`). |
|
|
135
|
+
| `inParam` | `any` | The primary argument passed to the leaf method. |
|
|
136
|
+
| `inArgs` | `any[]` | Array of all additional arguments passed beyond `inParam`. |
|
|
137
|
+
| `inLeafSpec` | `object \| undefined` | The resolved leaf definition object found in `source.json`. |
|
|
138
|
+
| `inSource` | `object` | The complete raw `source` schema object. |
|
|
139
|
+
| `inPathSegments` | `string[]` | Array of path segments (e.g. `["app", "users", "profile", "fetch"]`). |
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## 🛡️ Input Validation & Error Handling
|
|
144
|
+
|
|
145
|
+
`@keshavsoft/api-tree` performs strict pre-flight validation to catch contract misconfigurations early:
|
|
146
|
+
|
|
147
|
+
- **`source`**: Must be a non-null plain JSON object (throws `TypeError: source must be a JSON object.`).
|
|
148
|
+
- **`apiPaths`**: Must be an array of non-empty strings without empty segments (e.g., `"users..fetch"` or `"users."` throws `TypeError`).
|
|
149
|
+
- **`executor`**: Must be a valid callable function (throws `TypeError: executor must be a function.`).
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## 🌍 Real-World Architecture Examples
|
|
154
|
+
|
|
155
|
+
### Example A: Decoupling TallyPrime Runtimes
|
|
156
|
+
```javascript
|
|
157
|
+
import apiTree from "@keshavsoft/api-tree";
|
|
158
|
+
import { source, apiPaths } from "tally-spec";
|
|
159
|
+
import tallyXmlExecutor from "./tallyXmlExecutor.js";
|
|
160
|
+
|
|
161
|
+
// Generates app.masters.unit.all(), app.company.fetch(), etc.
|
|
162
|
+
const tally = apiTree(source, apiPaths, tallyXmlExecutor);
|
|
163
|
+
const units = await tally.masters.unit.all();
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Example B: Dynamic HTTP / REST Client
|
|
167
|
+
```javascript
|
|
168
|
+
import apiTree from "@keshavsoft/api-tree";
|
|
169
|
+
|
|
170
|
+
const endpoints = {
|
|
171
|
+
api: {
|
|
172
|
+
v1: {
|
|
173
|
+
users: { get: { method: "GET", url: "/api/v1/users" } }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const client = apiTree(endpoints, ["api.v1.users.get"], async ({ inLeafSpec, inParam }) => {
|
|
179
|
+
const res = await fetch(`${inLeafSpec.url}/${inParam}`, { method: inLeafSpec.method });
|
|
180
|
+
return res.json();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const user = await client.v1.users.get(42);
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 🧪 Testing
|
|
189
|
+
|
|
190
|
+
The package includes a comprehensive test suite using Node's native test runner:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npm test
|
|
91
194
|
```
|
|
92
195
|
|
|
93
|
-
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 📄 License
|
|
199
|
+
|
|
200
|
+
MIT © [KeshavSoft](https://github.com/keshavsoft)
|
package/docs/index.html
CHANGED
|
@@ -1,26 +1,298 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
-
<title
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
<
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>@keshavsoft/api-tree — Declarative Runtime API Tree Builder</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #0b0f19;
|
|
13
|
+
--card-bg: #111827;
|
|
14
|
+
--card-border: #1f2937;
|
|
15
|
+
--text: #f3f4f6;
|
|
16
|
+
--text-muted: #9ca3af;
|
|
17
|
+
--primary: #3b82f6;
|
|
18
|
+
--primary-light: #60a5fa;
|
|
19
|
+
--accent: #10b981;
|
|
20
|
+
--code-bg: #030712;
|
|
21
|
+
--border: #374151;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
25
|
+
body {
|
|
26
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
27
|
+
background: var(--bg);
|
|
28
|
+
color: var(--text);
|
|
29
|
+
line-height: 1.6;
|
|
30
|
+
padding: 0 1.5rem 4rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.container {
|
|
34
|
+
max-width: 900px;
|
|
35
|
+
margin: 0 auto;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
header {
|
|
39
|
+
padding: 4rem 0 2.5rem;
|
|
40
|
+
border-bottom: 1px solid var(--border);
|
|
41
|
+
margin-bottom: 3rem;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.badge-bar {
|
|
45
|
+
display: flex;
|
|
46
|
+
gap: 0.5rem;
|
|
47
|
+
margin-bottom: 1rem;
|
|
48
|
+
flex-wrap: wrap;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.badge {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
padding: 0.25rem 0.65rem;
|
|
55
|
+
border-radius: 9999px;
|
|
56
|
+
font-size: 0.75rem;
|
|
57
|
+
font-weight: 600;
|
|
58
|
+
background: #1e293b;
|
|
59
|
+
color: #94a3b8;
|
|
60
|
+
border: 1px solid #334155;
|
|
61
|
+
}
|
|
62
|
+
.badge.green { background: #064e3b; color: #34d399; border-color: #059669; }
|
|
63
|
+
.badge.blue { background: #1e3a8a; color: #93c5fd; border-color: #2563eb; }
|
|
64
|
+
|
|
65
|
+
h1 {
|
|
66
|
+
font-size: 2.75rem;
|
|
67
|
+
font-weight: 800;
|
|
68
|
+
letter-spacing: -0.03em;
|
|
69
|
+
margin-bottom: 0.75rem;
|
|
70
|
+
background: linear-gradient(135deg, #ffffff 0%, #93c5fd 100%);
|
|
71
|
+
-webkit-background-clip: text;
|
|
72
|
+
-webkit-text-fill-color: transparent;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.tagline {
|
|
76
|
+
font-size: 1.25rem;
|
|
77
|
+
color: var(--text-muted);
|
|
78
|
+
max-width: 700px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
h2 {
|
|
82
|
+
font-size: 1.75rem;
|
|
83
|
+
font-weight: 700;
|
|
84
|
+
margin: 2.5rem 0 1rem;
|
|
85
|
+
color: #ffffff;
|
|
86
|
+
border-bottom: 1px solid var(--border);
|
|
87
|
+
padding-bottom: 0.5rem;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
h3 {
|
|
91
|
+
font-size: 1.25rem;
|
|
92
|
+
font-weight: 600;
|
|
93
|
+
margin: 1.5rem 0 0.75rem;
|
|
94
|
+
color: var(--primary-light);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
p, ul {
|
|
98
|
+
color: #d1d5db;
|
|
99
|
+
margin-bottom: 1.25rem;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ul { padding-left: 1.5rem; }
|
|
103
|
+
li { margin-bottom: 0.5rem; }
|
|
104
|
+
|
|
105
|
+
.formula-box {
|
|
106
|
+
background: linear-gradient(180deg, rgba(30, 58, 138, 0.2) 0%, rgba(17, 24, 39, 0.6) 100%);
|
|
107
|
+
border: 1px solid #2563eb;
|
|
108
|
+
border-radius: 12px;
|
|
109
|
+
padding: 1.5rem;
|
|
110
|
+
margin: 2rem 0;
|
|
111
|
+
text-align: center;
|
|
112
|
+
font-size: 1.15rem;
|
|
113
|
+
font-weight: 600;
|
|
114
|
+
color: #bfdbfe;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pre {
|
|
118
|
+
background: var(--code-bg);
|
|
119
|
+
border: 1px solid var(--card-border);
|
|
120
|
+
border-radius: 8px;
|
|
121
|
+
padding: 1.25rem;
|
|
122
|
+
overflow-x: auto;
|
|
123
|
+
font-family: 'Fira Code', monospace;
|
|
124
|
+
font-size: 0.9rem;
|
|
125
|
+
margin-bottom: 1.5rem;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
code {
|
|
129
|
+
font-family: 'Fira Code', monospace;
|
|
130
|
+
color: #93c5fd;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
pre code {
|
|
134
|
+
color: #e2e8f0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.grid {
|
|
138
|
+
display: grid;
|
|
139
|
+
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
140
|
+
gap: 1.25rem;
|
|
141
|
+
margin: 1.5rem 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.card {
|
|
145
|
+
background: var(--card-bg);
|
|
146
|
+
border: 1px solid var(--card-border);
|
|
147
|
+
border-radius: 10px;
|
|
148
|
+
padding: 1.25rem;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.card h4 {
|
|
152
|
+
color: #60a5fa;
|
|
153
|
+
margin-bottom: 0.5rem;
|
|
154
|
+
font-size: 1.05rem;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.card p {
|
|
158
|
+
color: var(--text-muted);
|
|
159
|
+
font-size: 0.9rem;
|
|
160
|
+
margin-bottom: 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
table {
|
|
164
|
+
width: 100%;
|
|
165
|
+
border-collapse: collapse;
|
|
166
|
+
margin: 1.5rem 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
th, td {
|
|
170
|
+
border: 1px solid var(--border);
|
|
171
|
+
padding: 0.75rem 1rem;
|
|
172
|
+
text-align: left;
|
|
173
|
+
font-size: 0.9rem;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
th {
|
|
177
|
+
background: var(--card-bg);
|
|
178
|
+
color: #ffffff;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
td {
|
|
182
|
+
background: rgba(17, 24, 39, 0.4);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
footer {
|
|
186
|
+
margin-top: 4rem;
|
|
187
|
+
padding-top: 2rem;
|
|
188
|
+
border-top: 1px solid var(--border);
|
|
189
|
+
color: var(--text-muted);
|
|
190
|
+
font-size: 0.85rem;
|
|
191
|
+
text-align: center;
|
|
192
|
+
}
|
|
193
|
+
</style>
|
|
194
|
+
</head>
|
|
195
|
+
<body>
|
|
196
|
+
<div class="container">
|
|
197
|
+
<header>
|
|
198
|
+
<div class="badge-bar">
|
|
199
|
+
<span class="badge blue">@keshavsoft/api-tree</span>
|
|
200
|
+
<span class="badge green">v2.0.0</span>
|
|
201
|
+
<span class="badge">Zero Dependencies</span>
|
|
202
|
+
<span class="badge">Node >= 20.10</span>
|
|
203
|
+
</div>
|
|
204
|
+
<h1>@keshavsoft/api-tree</h1>
|
|
205
|
+
<p class="tagline">A declarative, ultra-lean routing engine that turns source schemas, API paths, and an external executor into a callable runtime API tree.</p>
|
|
206
|
+
</header>
|
|
207
|
+
|
|
208
|
+
<div class="formula-box">
|
|
209
|
+
Runtime Tree = Source Schema (JSON) + API Paths (Routes) + Custom Executor
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<h2>Core Architecture</h2>
|
|
213
|
+
<p><code>@keshavsoft/api-tree</code> decouples specification contracts from runtime execution:</p>
|
|
214
|
+
<div class="grid">
|
|
215
|
+
<div class="card">
|
|
216
|
+
<h4>1. Source Schema</h4>
|
|
217
|
+
<p>The single source of truth containing leaf specifications, metadata, and domain contracts.</p>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="card">
|
|
220
|
+
<h4>2. API Paths</h4>
|
|
221
|
+
<p>A flat, declarative array of dotted routes (e.g. <code>app.users.profile.fetch</code>).</p>
|
|
222
|
+
</div>
|
|
223
|
+
<div class="card">
|
|
224
|
+
<h4>3. Custom Executor</h4>
|
|
225
|
+
<p>A pure execution handler that receives standardized context and executes the operation.</p>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<h2>Quick Start</h2>
|
|
230
|
+
<pre><code>import apiTree from "@keshavsoft/api-tree";
|
|
231
|
+
|
|
232
|
+
const source = {
|
|
233
|
+
app: {
|
|
234
|
+
users: {
|
|
235
|
+
profile: {
|
|
236
|
+
fetch: { action: "fetch", resource: "User" }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const apiPaths = ["app.users.profile.fetch"];
|
|
243
|
+
|
|
244
|
+
const executor = async ({ inRoutePath, inLeafSpec, inParam }) => {
|
|
245
|
+
return { id: inParam, action: inLeafSpec.action };
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const api = apiTree(source, apiPaths, executor);
|
|
249
|
+
|
|
250
|
+
// Call the navigable tree!
|
|
251
|
+
const result = await api.users.profile.fetch("123");
|
|
252
|
+
console.log(result); // { id: "123", action: "fetch" }</code></pre>
|
|
253
|
+
|
|
254
|
+
<h2>Execution Context</h2>
|
|
255
|
+
<p>When an attached leaf function is invoked, your executor receives a rich context object:</p>
|
|
256
|
+
<table>
|
|
257
|
+
<thead>
|
|
258
|
+
<tr>
|
|
259
|
+
<th>Property</th>
|
|
260
|
+
<th>Type</th>
|
|
261
|
+
<th>Description</th>
|
|
262
|
+
</tr>
|
|
263
|
+
</thead>
|
|
264
|
+
<tbody>
|
|
265
|
+
<tr>
|
|
266
|
+
<td><code>inRoutePath</code></td>
|
|
267
|
+
<td>string</td>
|
|
268
|
+
<td>The full dot-notation route path.</td>
|
|
269
|
+
</tr>
|
|
270
|
+
<tr>
|
|
271
|
+
<td><code>inParam</code></td>
|
|
272
|
+
<td>any</td>
|
|
273
|
+
<td>First argument passed to the leaf.</td>
|
|
274
|
+
</tr>
|
|
275
|
+
<tr>
|
|
276
|
+
<td><code>inArgs</code></td>
|
|
277
|
+
<td>any[]</td>
|
|
278
|
+
<td>Array of all additional arguments.</td>
|
|
279
|
+
</tr>
|
|
280
|
+
<tr>
|
|
281
|
+
<td><code>inLeafSpec</code></td>
|
|
282
|
+
<td>object</td>
|
|
283
|
+
<td>Pre-resolved leaf definition from source.json.</td>
|
|
284
|
+
</tr>
|
|
285
|
+
<tr>
|
|
286
|
+
<td><code>inSource</code></td>
|
|
287
|
+
<td>object</td>
|
|
288
|
+
<td>The raw source schema object.</td>
|
|
289
|
+
</tr>
|
|
290
|
+
</tbody>
|
|
291
|
+
</table>
|
|
292
|
+
|
|
293
|
+
<footer>
|
|
294
|
+
<p>MIT License © KeshavSoft. Distributed via npm.</p>
|
|
295
|
+
</footer>
|
|
296
|
+
</div>
|
|
297
|
+
</body>
|
|
298
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@keshavsoft/api-tree",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Build a callable runtime API tree from API paths, source JSON, and an external executor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"LICENSE"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"test": "node --test test/test.js",
|
|
23
|
+
"test": "node --test test/test.js test/test-v2.js",
|
|
24
24
|
"prepublishOnly": "npm test"
|
|
25
25
|
},
|
|
26
26
|
"repository": {
|
package/src/index.d.ts
CHANGED
|
@@ -1,19 +1,69 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Context passed to the executor function when a leaf method is invoked.
|
|
3
|
+
*/
|
|
4
|
+
export interface ApiTreeExecutionContext<TSource = Record<string, any>, TLeafSpec = any> {
|
|
5
|
+
/** The full dot-notation path of the invoked leaf (e.g. "app.users.profile.fetch") */
|
|
6
|
+
inRoutePath: string;
|
|
7
|
+
/** The first argument passed to the leaf method */
|
|
8
|
+
inParam?: any;
|
|
9
|
+
/** Array of all remaining arguments passed to the leaf method */
|
|
10
|
+
inArgs: any[];
|
|
11
|
+
/** The complete source JSON object */
|
|
12
|
+
inSource: TSource;
|
|
13
|
+
/** The resolved leaf specification object from source.json, if found */
|
|
14
|
+
inLeafSpec?: TLeafSpec;
|
|
15
|
+
/** The segments of the route path split by "." */
|
|
16
|
+
inPathSegments: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An executor function responsible for handling the execution of an invoked leaf.
|
|
21
|
+
*/
|
|
22
|
+
export type ApiTreeExecutor<TSource = Record<string, any>, TReturn = any> = (
|
|
23
|
+
context: ApiTreeExecutionContext<TSource>
|
|
24
|
+
) => Promise<TReturn> | TReturn;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Options for customizing api-tree building behavior.
|
|
28
|
+
*/
|
|
29
|
+
export interface ApiTreeOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Whether to unwrap a single shared root namespace.
|
|
32
|
+
* Default: true when all paths share one root namespace; false when paths have different roots.
|
|
33
|
+
*/
|
|
34
|
+
inUnwrapRoot?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Named argument object for apiTree.
|
|
39
|
+
*/
|
|
40
|
+
export interface ApiTreeParams<TSource = Record<string, any>, TReturn = any> {
|
|
41
|
+
inSource: TSource;
|
|
42
|
+
inApiPaths: string[];
|
|
43
|
+
inExecutor: ApiTreeExecutor<TSource, TReturn>;
|
|
44
|
+
inOptions?: ApiTreeOptions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Builds and returns a callable runtime API tree from source JSON, API paths, and an executor.
|
|
49
|
+
*
|
|
50
|
+
* @param source Source JSON object containing schema definitions
|
|
51
|
+
* @param apiPaths Array of dot-separated route paths (e.g. ["app.users.fetch"])
|
|
52
|
+
* @param executor Execution function invoked when a leaf is called
|
|
53
|
+
* @param options Optional configuration
|
|
54
|
+
*/
|
|
55
|
+
export declare function apiTree<TSource = Record<string, any>, TReturn = any>(
|
|
56
|
+
source: TSource,
|
|
57
|
+
apiPaths: string[],
|
|
58
|
+
executor: ApiTreeExecutor<TSource, TReturn>,
|
|
59
|
+
options?: ApiTreeOptions
|
|
60
|
+
): Record<string, any>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Named argument signature for apiTree.
|
|
64
|
+
*/
|
|
65
|
+
export declare function apiTree<TSource = Record<string, any>, TReturn = any>(
|
|
66
|
+
params: ApiTreeParams<TSource, TReturn>
|
|
67
|
+
): Record<string, any>;
|
|
68
|
+
|
|
69
|
+
export default apiTree;
|
package/src/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { default } from "./
|
|
2
|
-
export * from "./
|
|
1
|
+
export { default } from "./v2/index.js";
|
|
2
|
+
export * from "./v2/index.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"app": {
|
|
3
|
+
"users": {
|
|
4
|
+
"profile": {
|
|
5
|
+
"fetch": {
|
|
6
|
+
"action": "fetch",
|
|
7
|
+
"resource": "User",
|
|
8
|
+
"description": "Fetch user profile details"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"reports": {
|
|
13
|
+
"summary": {
|
|
14
|
+
"fetch": {
|
|
15
|
+
"action": "fetch",
|
|
16
|
+
"resource": "Report",
|
|
17
|
+
"description": "Fetch summary report"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/v2/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import validate from "./internal-working/validate/index.js";
|
|
2
|
+
import routeStart from "./internal-working/route/index.js";
|
|
3
|
+
|
|
4
|
+
const start = (inSource, inApiPaths, inExecutor, inOptions) => {
|
|
5
|
+
let source = inSource;
|
|
6
|
+
let paths = inApiPaths;
|
|
7
|
+
let executor = inExecutor;
|
|
8
|
+
let options = inOptions;
|
|
9
|
+
|
|
10
|
+
if (inSource && typeof inSource === "object" && "inSource" in inSource) {
|
|
11
|
+
source = inSource.inSource;
|
|
12
|
+
paths = inSource.inApiPaths;
|
|
13
|
+
executor = inSource.inExecutor;
|
|
14
|
+
options = inSource.inOptions;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
validate({
|
|
18
|
+
inSource: source,
|
|
19
|
+
inApiPaths: paths,
|
|
20
|
+
inExecutor: executor,
|
|
21
|
+
inOptions: options
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return routeStart({
|
|
25
|
+
inApiPaths: paths,
|
|
26
|
+
inSource: source,
|
|
27
|
+
inExecutor: executor,
|
|
28
|
+
inOptions: options
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default start;
|
|
33
|
+
export { start, start as apiTree };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import createLeafHandler from "./createLeafHandler.js";
|
|
2
|
+
import resolveLeafSpec from "./resolveLeafSpec.js";
|
|
3
|
+
|
|
4
|
+
const startFunc = ({ inTree, inPath, inSource, inExecutor }) => {
|
|
5
|
+
const localTree = inTree;
|
|
6
|
+
const localPath = inPath;
|
|
7
|
+
const localSource = inSource;
|
|
8
|
+
const localExecutor = inExecutor;
|
|
9
|
+
|
|
10
|
+
const parts = localPath.split(".");
|
|
11
|
+
const leafName = parts.pop();
|
|
12
|
+
|
|
13
|
+
let branch = localTree;
|
|
14
|
+
for (const segment of parts) {
|
|
15
|
+
branch[segment] ??= {};
|
|
16
|
+
branch = branch[segment];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const leafSpec = resolveLeafSpec({
|
|
20
|
+
inSource: localSource,
|
|
21
|
+
inPath: localPath
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const handler = createLeafHandler({
|
|
25
|
+
inPath: localPath,
|
|
26
|
+
inSource: localSource,
|
|
27
|
+
inExecutor: localExecutor,
|
|
28
|
+
inLeafSpec: leafSpec
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (branch[leafName] && typeof branch[leafName] === "object") {
|
|
32
|
+
Object.assign(handler, branch[leafName]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
branch[leafName] = handler;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default startFunc;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const startFunc = ({ inPath, inSource, inExecutor, inLeafSpec }) => {
|
|
2
|
+
const localPath = inPath;
|
|
3
|
+
const localSource = inSource;
|
|
4
|
+
const localExecutor = inExecutor;
|
|
5
|
+
const localLeafSpec = inLeafSpec;
|
|
6
|
+
const localPathSegments = localPath.split(".");
|
|
7
|
+
|
|
8
|
+
const handler = async (inParam, ...inArgs) => {
|
|
9
|
+
const localParam = inParam;
|
|
10
|
+
const localArgs = inArgs;
|
|
11
|
+
|
|
12
|
+
return await localExecutor({
|
|
13
|
+
inRoutePath: localPath,
|
|
14
|
+
inParam: localParam,
|
|
15
|
+
inArgs: localArgs,
|
|
16
|
+
inSource: localSource,
|
|
17
|
+
inLeafSpec: localLeafSpec,
|
|
18
|
+
inPathSegments: localPathSegments
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
return handler;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default startFunc;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import attachPath from "./attachPath.js";
|
|
2
|
+
|
|
3
|
+
const startFunc = ({ inApiPaths, inSource, inExecutor, inOptions }) => {
|
|
4
|
+
const localApiPaths = inApiPaths;
|
|
5
|
+
const localSource = inSource;
|
|
6
|
+
const localExecutor = inExecutor;
|
|
7
|
+
const localOptions = inOptions ?? {};
|
|
8
|
+
|
|
9
|
+
const tree = {};
|
|
10
|
+
|
|
11
|
+
for (const path of localApiPaths) {
|
|
12
|
+
attachPath({
|
|
13
|
+
inTree: tree,
|
|
14
|
+
inPath: path,
|
|
15
|
+
inSource: localSource,
|
|
16
|
+
inExecutor: localExecutor
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (localApiPaths.length === 0) {
|
|
21
|
+
return tree;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rootSegments = new Set(localApiPaths.map((path) => path.split(".")[0]));
|
|
25
|
+
|
|
26
|
+
if (rootSegments.size === 1) {
|
|
27
|
+
const sharedRoot = rootSegments.values().next().value;
|
|
28
|
+
const unwrap = localOptions.inUnwrapRoot !== false;
|
|
29
|
+
return unwrap && tree[sharedRoot] ? tree[sharedRoot] : tree;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return tree;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default startFunc;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const startFunc = ({ inSource, inPath }) => {
|
|
2
|
+
const localSource = inSource;
|
|
3
|
+
const localPath = inPath;
|
|
4
|
+
|
|
5
|
+
if (!localSource || typeof localSource !== "object" || !localPath) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const segments = localPath.split(".");
|
|
10
|
+
let current = localSource;
|
|
11
|
+
|
|
12
|
+
for (const segment of segments) {
|
|
13
|
+
if (current === null || typeof current !== "object" || !(segment in current)) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
current = current[segment];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return current;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default startFunc;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import isPlainObject from "./isPlainObject.js";
|
|
2
|
+
|
|
3
|
+
const startFunc = ({ inSource, inApiPaths, inExecutor, inOptions }) => {
|
|
4
|
+
const localSource = inSource;
|
|
5
|
+
const localApiPaths = inApiPaths;
|
|
6
|
+
const localExecutor = inExecutor;
|
|
7
|
+
const localOptions = inOptions;
|
|
8
|
+
|
|
9
|
+
if (!isPlainObject({ inValue: localSource })) {
|
|
10
|
+
throw new TypeError("source must be a JSON object.");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!Array.isArray(localApiPaths)) {
|
|
14
|
+
throw new TypeError("apiPaths must be an array of strings.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
localApiPaths.forEach((path, index) => {
|
|
18
|
+
if (typeof path !== "string") {
|
|
19
|
+
throw new TypeError(`apiPaths must be an array of strings. apiPaths[${index}] must be a string.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const trimmed = path.trim();
|
|
23
|
+
if (trimmed.length === 0) {
|
|
24
|
+
throw new TypeError(`apiPaths[${index}] must not be an empty string.`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const segments = trimmed.split(".");
|
|
28
|
+
if (segments.some((segment) => segment.length === 0)) {
|
|
29
|
+
throw new TypeError(`apiPaths[${index}] '${path}' contains an invalid empty path segment.`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (typeof localExecutor !== "function") {
|
|
34
|
+
throw new TypeError("executor must be a function.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (localOptions !== undefined && !isPlainObject({ inValue: localOptions })) {
|
|
38
|
+
throw new TypeError("options must be an object if provided.");
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default startFunc;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const startFunc = ({ inValue }) => {
|
|
2
|
+
const localValue = inValue;
|
|
3
|
+
|
|
4
|
+
if (localValue === null || typeof localValue !== "object") {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const prototype = Object.getPrototypeOf(localValue);
|
|
9
|
+
return prototype === Object.prototype || prototype === null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default startFunc;
|