vovk-cli 0.0.1-draft.40 → 0.0.1-draft.41
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/client-templates/compiled/client.d.ts.ejs +24 -0
- package/client-templates/compiled/client.js.ejs +26 -0
- package/client-templates/python/__init__.py +276 -0
- package/client-templates/ts/index.ts.ejs +36 -0
- package/dist/dev/index.mjs +11 -7
- package/dist/generateClient.d.mts +6 -1
- package/dist/generateClient.mjs +72 -78
- package/dist/index.mjs +11 -5
- package/dist/locateSegments.d.mts +7 -1
- package/dist/locateSegments.mjs +6 -3
- package/dist/new/newModule.mjs +1 -1
- package/dist/new/newSegment.mjs +3 -1
- package/dist/types.d.mts +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%- '// auto-generated\n/* eslint-disable */' %>
|
|
2
|
+
import type { clientizeController, VovkClientFetcher } from 'vovk/client';
|
|
3
|
+
import type { promisifyWorker } from 'vovk/worker';
|
|
4
|
+
import type fetcher from '<%= fetcherClientImportPath %>';
|
|
5
|
+
|
|
6
|
+
<% segments.forEach((segment, i) => { %>
|
|
7
|
+
import type { Controllers as Controllers<%= i %>, Workers as Workers<%= i %> } from "<%= segment.segmentImportPath %>";
|
|
8
|
+
<% }) %>
|
|
9
|
+
|
|
10
|
+
type Options = typeof fetcher extends VovkClientFetcher<infer U> ? U : never;
|
|
11
|
+
|
|
12
|
+
<% segments.forEach((segment, i) => {
|
|
13
|
+
const segSchema = segmentsSchema[segment.segmentName];
|
|
14
|
+
if (!segSchema || !segSchema.emitSchema) return;
|
|
15
|
+
const controllers = Object.keys(segSchema.controllers);
|
|
16
|
+
const workers = Object.keys(segSchema.workers);
|
|
17
|
+
%>
|
|
18
|
+
<% controllers.forEach((key) => { %>
|
|
19
|
+
export const <%= key %>: ReturnType<typeof clientizeController<Controllers<%= i %>["<%= key %>"], Options>>;
|
|
20
|
+
<% }) %>
|
|
21
|
+
<% workers.forEach((key) => { %>
|
|
22
|
+
export const <%= key %>: ReturnType<typeof promisifyWorker<Workers<%= i %>["<%= key %>"]>>;
|
|
23
|
+
<% }) %>
|
|
24
|
+
<% }) %>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<%- '// auto-generated\n/* eslint-disable */' %>
|
|
2
|
+
const { clientizeController } = require('vovk/client');
|
|
3
|
+
const { promisifyWorker } = require('vovk/worker');
|
|
4
|
+
const { default: fetcher } = require('<%= fetcherClientImportPath %>');
|
|
5
|
+
const schema = require('<%= schemaOutImportPath %>');
|
|
6
|
+
|
|
7
|
+
const { default: validateOnClient = null } = <%- validateOnClientImportPath ? `require('${validateOnClientImportPath}')` : '{}'%>;
|
|
8
|
+
const apiRoot = '<%= apiEntryPoint %>';
|
|
9
|
+
|
|
10
|
+
<% segments.forEach((segment) => {
|
|
11
|
+
const segSchema = segmentsSchema[segment.segmentName];
|
|
12
|
+
if (!segSchema || !segSchema.emitSchema) return;
|
|
13
|
+
const controllers = Object.keys(segSchema.controllers);
|
|
14
|
+
const workers = Object.keys(segSchema.workers);
|
|
15
|
+
%>
|
|
16
|
+
<% controllers.forEach((key) => { %>
|
|
17
|
+
exports.<%= key %> = clientizeController(
|
|
18
|
+
schema['<%= segment.segmentName %>'].controllers.<%= key %>,
|
|
19
|
+
'<%= segment.segmentName %>',
|
|
20
|
+
{ fetcher, validateOnClient, defaultOptions: { apiRoot } }
|
|
21
|
+
);
|
|
22
|
+
<% }) %>
|
|
23
|
+
<% workers.forEach((key) => { %>
|
|
24
|
+
exports.<%= key %> = promisifyWorker(null, schema['<%= segment.segmentName %>'].workers.<%= key %>);
|
|
25
|
+
<% }) %>
|
|
26
|
+
<% }) %>
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import requests
|
|
4
|
+
from jsonschema import validate
|
|
5
|
+
from jsonschema.exceptions import ValidationError
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
__all__ = [] # We'll populate this dynamically
|
|
9
|
+
|
|
10
|
+
class ServerError(Exception):
|
|
11
|
+
"""Custom exception for server errors that include statusCode and/or message."""
|
|
12
|
+
def __init__(self, status_code: int, message: str):
|
|
13
|
+
super().__init__(f"[{status_code}] {message}")
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.server_message = message
|
|
16
|
+
|
|
17
|
+
def _load_full_schema() -> dict:
|
|
18
|
+
"""
|
|
19
|
+
Loads the 'full-schema.json' file (which must sit in the same folder as this __init__.py).
|
|
20
|
+
Returns it as a Python dictionary.
|
|
21
|
+
"""
|
|
22
|
+
current_dir = os.path.dirname(__file__)
|
|
23
|
+
schema_path = os.path.join(current_dir, "full-schema.json")
|
|
24
|
+
with open(schema_path, "r", encoding="utf-8") as f:
|
|
25
|
+
return json.load(f)
|
|
26
|
+
|
|
27
|
+
class _RPCBase:
|
|
28
|
+
"""
|
|
29
|
+
Base class that provides a validated HTTP request mechanism.
|
|
30
|
+
All dynamic RPC classes will subclass this.
|
|
31
|
+
"""
|
|
32
|
+
def __init__(self, base_url: str):
|
|
33
|
+
self.base_url = base_url.rstrip("/")
|
|
34
|
+
|
|
35
|
+
def _handle_stream_response(self, resp: requests.Response):
|
|
36
|
+
"""
|
|
37
|
+
Returns a generator that yields JSON objects from a newline-delimited stream.
|
|
38
|
+
It attempts to parse each line as valid JSON.
|
|
39
|
+
If we encounter an 'isError' structure, we raise a ServerError immediately.
|
|
40
|
+
"""
|
|
41
|
+
buffer = ""
|
|
42
|
+
|
|
43
|
+
# We'll use resp.iter_content(...) to handle partial chunks
|
|
44
|
+
# decode_unicode=True gives us str chunks in Python 3.
|
|
45
|
+
for chunk in resp.iter_content(chunk_size=None, decode_unicode=True):
|
|
46
|
+
buffer += chunk
|
|
47
|
+
lines = buffer.split("\n")
|
|
48
|
+
# We'll parse every line except the last, which might still be partial
|
|
49
|
+
for line in lines[:-1]:
|
|
50
|
+
line = line.strip()
|
|
51
|
+
if not line:
|
|
52
|
+
continue # skip empty lines
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(line)
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
# Could happen if line is incomplete, but we got a newline anyway
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
# If the server signals an error in-stream
|
|
61
|
+
if data.get("isError") and "reason" in data:
|
|
62
|
+
resp.close()
|
|
63
|
+
raise ServerError(resp.status_code, str(data["reason"]))
|
|
64
|
+
|
|
65
|
+
yield data
|
|
66
|
+
|
|
67
|
+
# The last piece (lines[-1]) may be incomplete
|
|
68
|
+
buffer = lines[-1]
|
|
69
|
+
|
|
70
|
+
# If there's leftover data in the buffer (no trailing newline at end):
|
|
71
|
+
leftover = buffer.strip()
|
|
72
|
+
if leftover:
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(leftover)
|
|
75
|
+
if data.get("isError") and "reason" in data:
|
|
76
|
+
resp.close()
|
|
77
|
+
raise ServerError(resp.status_code, str(data["reason"]))
|
|
78
|
+
yield data
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
# Not valid JSON or partial leftover
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
# End of stream; close the connection
|
|
84
|
+
resp.close()
|
|
85
|
+
|
|
86
|
+
def _request(
|
|
87
|
+
self,
|
|
88
|
+
method: str,
|
|
89
|
+
endpoint_path: str,
|
|
90
|
+
query: Optional[dict] = None,
|
|
91
|
+
body: Optional[dict] = None,
|
|
92
|
+
query_schema: Optional[dict] = None,
|
|
93
|
+
body_schema: Optional[dict] = None,
|
|
94
|
+
disable_client_validation: bool = False
|
|
95
|
+
):
|
|
96
|
+
"""
|
|
97
|
+
1. If disable_client_validation is False, validates `query` & `body` (if schemas).
|
|
98
|
+
2. Makes an HTTP request (using `requests` with stream=True).
|
|
99
|
+
3. If the response is not 2xx:
|
|
100
|
+
- parse JSON for a possible error structure
|
|
101
|
+
- or raise requests.HTTPError if not available
|
|
102
|
+
4. If 'x-vovk-stream' == 'true', return a generator that yields JSON objects.
|
|
103
|
+
5. Otherwise, parse and return the actual response (JSON -> dict or fallback to text).
|
|
104
|
+
"""
|
|
105
|
+
# Validate query and body if schemas are provided AND validation not disabled
|
|
106
|
+
if not disable_client_validation:
|
|
107
|
+
if query_schema:
|
|
108
|
+
validate(instance=query or {}, schema=query_schema)
|
|
109
|
+
if body_schema:
|
|
110
|
+
validate(instance=body or {}, schema=body_schema)
|
|
111
|
+
|
|
112
|
+
# Build the final URL
|
|
113
|
+
url = f"{self.base_url}/{endpoint_path}"
|
|
114
|
+
|
|
115
|
+
# Make the request (stream=True to handle streaming)
|
|
116
|
+
resp = requests.request(
|
|
117
|
+
method=method,
|
|
118
|
+
url=url,
|
|
119
|
+
params=query,
|
|
120
|
+
json=body,
|
|
121
|
+
stream=True
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Check if status is not 2xx
|
|
125
|
+
if not resp.ok:
|
|
126
|
+
try:
|
|
127
|
+
# Attempt to parse JSON error
|
|
128
|
+
data = resp.json()
|
|
129
|
+
# Example: { "statusCode": 400, "message": "Zod validation failed...", "isError": true }
|
|
130
|
+
if data.get("isError"):
|
|
131
|
+
status_code = data.get("statusCode", resp.status_code)
|
|
132
|
+
message = data.get("message", resp.text)
|
|
133
|
+
resp.close()
|
|
134
|
+
raise ServerError(status_code, message)
|
|
135
|
+
else:
|
|
136
|
+
# Not the structured error we expect - fallback
|
|
137
|
+
resp.raise_for_status()
|
|
138
|
+
except ValueError:
|
|
139
|
+
# If parsing fails, fallback
|
|
140
|
+
resp.raise_for_status()
|
|
141
|
+
|
|
142
|
+
# If we get here, resp is 2xx. Check if streaming is requested.
|
|
143
|
+
if resp.headers.get("x-vovk-stream", "").lower() == "true":
|
|
144
|
+
return self._handle_stream_response(resp)
|
|
145
|
+
|
|
146
|
+
# Non-streaming: parse JSON or return text
|
|
147
|
+
content_type = resp.headers.get("Content-Type", "").lower()
|
|
148
|
+
try:
|
|
149
|
+
if "application/json" in content_type:
|
|
150
|
+
result = resp.json() # parse the body as JSON
|
|
151
|
+
else:
|
|
152
|
+
result = resp.text # fallback if not JSON
|
|
153
|
+
finally:
|
|
154
|
+
# In either case, we can close the connection now since we're reading full body
|
|
155
|
+
resp.close()
|
|
156
|
+
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_controller_class(
|
|
161
|
+
controller_name: str,
|
|
162
|
+
controller_spec: dict,
|
|
163
|
+
segment_name: str
|
|
164
|
+
):
|
|
165
|
+
"""
|
|
166
|
+
Builds a dynamic class (subclass of _RPCBase) for a single controller.
|
|
167
|
+
Instead of instance methods, we create class methods so we can call
|
|
168
|
+
them directly on the class (passing base_url, query, body, etc.).
|
|
169
|
+
|
|
170
|
+
The endpoints will be constructed as: `segmentName/prefix/path`.
|
|
171
|
+
If `prefix` or `path` contain placeholder segments like `:id`,
|
|
172
|
+
they can be replaced by passing a `params` dict, e.g. { "id": 123 }
|
|
173
|
+
which would convert "/foo/:id/bar" --> "/foo/123/bar"
|
|
174
|
+
"""
|
|
175
|
+
prefix = controller_spec.get("prefix", "").strip("/")
|
|
176
|
+
handlers = controller_spec.get("handlers", {})
|
|
177
|
+
|
|
178
|
+
class_attrs = {}
|
|
179
|
+
|
|
180
|
+
for handler_name, handler_data in handlers.items():
|
|
181
|
+
# HTTP method (e.g., "GET", "POST", etc.)
|
|
182
|
+
http_method = handler_data["httpMethod"]
|
|
183
|
+
|
|
184
|
+
# Path defined in the schema (may contain ":id", etc.)
|
|
185
|
+
path = handler_data["path"].strip("/")
|
|
186
|
+
|
|
187
|
+
# Combine "segmentName/prefix/path" into a single path
|
|
188
|
+
endpoint_path = f"{segment_name}/{prefix}/{path}".strip("/")
|
|
189
|
+
|
|
190
|
+
# Optional JSON schemas (for query/body)
|
|
191
|
+
validation = handler_data.get("validation", {})
|
|
192
|
+
query_schema = validation.get("query")
|
|
193
|
+
body_schema = validation.get("body")
|
|
194
|
+
|
|
195
|
+
def make_class_method(
|
|
196
|
+
m=http_method,
|
|
197
|
+
ep=endpoint_path,
|
|
198
|
+
q_schema=query_schema,
|
|
199
|
+
b_schema=body_schema,
|
|
200
|
+
name=handler_name
|
|
201
|
+
):
|
|
202
|
+
@classmethod
|
|
203
|
+
def handler(cls, base_url, *, query=None, body=None, params=None, disable_client_validation=False):
|
|
204
|
+
"""
|
|
205
|
+
Class method that instantiates the RPC class (with base_url)
|
|
206
|
+
and immediately calls _request on that instance.
|
|
207
|
+
|
|
208
|
+
:param base_url: The base URL of your API.
|
|
209
|
+
:param query: An optional dict for query parameters.
|
|
210
|
+
:param body: An optional dict for the request JSON body.
|
|
211
|
+
:param params: A dict for path substitutions, e.g. {"id": 42}
|
|
212
|
+
which will replace ":id" in the endpoint path.
|
|
213
|
+
:param disable_client_validation: If True, skip schema validation.
|
|
214
|
+
"""
|
|
215
|
+
final_endpoint_path = ep
|
|
216
|
+
|
|
217
|
+
# Perform path param substitution if needed
|
|
218
|
+
for param_key, param_val in (params or {}).items():
|
|
219
|
+
final_endpoint_path = final_endpoint_path.replace(
|
|
220
|
+
f":{param_key}",
|
|
221
|
+
str(param_val)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Instantiate and make the request
|
|
225
|
+
temp_instance = cls(base_url)
|
|
226
|
+
return temp_instance._request(
|
|
227
|
+
method=m,
|
|
228
|
+
endpoint_path=final_endpoint_path,
|
|
229
|
+
query=query,
|
|
230
|
+
body=body,
|
|
231
|
+
query_schema=q_schema,
|
|
232
|
+
body_schema=b_schema,
|
|
233
|
+
disable_client_validation=disable_client_validation
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
handler.__name__ = name
|
|
237
|
+
return handler
|
|
238
|
+
|
|
239
|
+
# Attach the generated class method for this handler
|
|
240
|
+
class_attrs[handler_name] = make_class_method()
|
|
241
|
+
|
|
242
|
+
# Dynamically create a new subclass of _RPCBase with those methods
|
|
243
|
+
return type(controller_name, (_RPCBase,), class_attrs)
|
|
244
|
+
|
|
245
|
+
def _load_controllers():
|
|
246
|
+
"""
|
|
247
|
+
Reads the entire 'full-schema.json',
|
|
248
|
+
iterates over each top-level segment (like 'xxx', 'yyy'),
|
|
249
|
+
extracts the segmentName + controllers,
|
|
250
|
+
and dynamically builds classes for each controller.
|
|
251
|
+
"""
|
|
252
|
+
data = _load_full_schema()
|
|
253
|
+
all_controllers = {}
|
|
254
|
+
|
|
255
|
+
for segment_key, segment_obj in data.items():
|
|
256
|
+
segment_name = segment_obj.get("segmentName", "").strip("/")
|
|
257
|
+
controllers = segment_obj.get("controllers", {})
|
|
258
|
+
|
|
259
|
+
for ctrl_name, ctrl_spec in controllers.items():
|
|
260
|
+
dynamic_class = _build_controller_class(
|
|
261
|
+
controller_name=ctrl_name,
|
|
262
|
+
controller_spec=ctrl_spec,
|
|
263
|
+
segment_name=segment_name
|
|
264
|
+
)
|
|
265
|
+
all_controllers[ctrl_name] = dynamic_class
|
|
266
|
+
|
|
267
|
+
return all_controllers
|
|
268
|
+
|
|
269
|
+
# Build all controllers at import time
|
|
270
|
+
_controllers_dict = _load_controllers()
|
|
271
|
+
|
|
272
|
+
# Export them at the top level
|
|
273
|
+
for ctrl_name, ctrl_class in _controllers_dict.items():
|
|
274
|
+
globals()[ctrl_name] = ctrl_class
|
|
275
|
+
__all__.append(ctrl_name)
|
|
276
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<%- '// auto-generated\n/* eslint-disable */' %>
|
|
2
|
+
import { clientizeController, type VovkClientFetcher } from 'vovk/client';
|
|
3
|
+
import { promisifyWorker } from 'vovk/worker';
|
|
4
|
+
import fetcher from '<%= fetcherClientImportPath %>';
|
|
5
|
+
import schema from '<%= schemaOutImportPath %>';
|
|
6
|
+
|
|
7
|
+
<% if (validateOnClientImportPath) { %>
|
|
8
|
+
import validateOnClient from '<%= validateOnClientImportPath %>';
|
|
9
|
+
<% } else { %>
|
|
10
|
+
const validateOnClient = undefined;
|
|
11
|
+
<% } %>
|
|
12
|
+
|
|
13
|
+
type Options = typeof fetcher extends VovkClientFetcher<infer U> ? U : never;
|
|
14
|
+
const apiRoot = '<%= apiEntryPoint %>';
|
|
15
|
+
|
|
16
|
+
<% segments.forEach((segment, i) => {
|
|
17
|
+
const segSchema = segmentsSchema[segment.segmentName];
|
|
18
|
+
if (!segSchema || !segSchema.emitSchema) return;
|
|
19
|
+
%>
|
|
20
|
+
import type { Controllers as Controllers<%= i %>, Workers as Workers<%= i %> } from "<%= segment.segmentImportPath %>";
|
|
21
|
+
|
|
22
|
+
<% Object.keys(segSchema.controllers).forEach((key) => { %>
|
|
23
|
+
export const <%= key %> = clientizeController<Controllers<%= i %>["<%= key %>"], Options>(
|
|
24
|
+
schema['<%= segment.segmentName %>'].controllers.<%= key %>,
|
|
25
|
+
'<%= segment.segmentName %>',
|
|
26
|
+
{ fetcher, validateOnClient, defaultOptions: { apiRoot } }
|
|
27
|
+
);
|
|
28
|
+
<% }) %>
|
|
29
|
+
|
|
30
|
+
<% Object.keys(segSchema.workers).forEach((key) => { %>
|
|
31
|
+
export const <%= key %> = promisifyWorker<Workers<%= i %>["<%= key %>"]>(
|
|
32
|
+
null,
|
|
33
|
+
schema['<%= segment.segmentName %>'].workers.<%= key %>
|
|
34
|
+
);
|
|
35
|
+
<% }) %>
|
|
36
|
+
<% }) %>
|
package/dist/dev/index.mjs
CHANGED
|
@@ -40,7 +40,11 @@ export class VovkDev {
|
|
|
40
40
|
const segmentName = getSegmentName(filePath);
|
|
41
41
|
this.#segments = this.#segments.find((s) => s.segmentName === segmentName)
|
|
42
42
|
? this.#segments
|
|
43
|
-
: [...this.#segments, {
|
|
43
|
+
: [...this.#segments, {
|
|
44
|
+
routeFilePath: filePath,
|
|
45
|
+
segmentName,
|
|
46
|
+
segmentImportPath: path.relative(config.clientOutDir, filePath) // TODO DRY locateSegments
|
|
47
|
+
}];
|
|
44
48
|
log.info(`${capitalize(formatLoggedSegmentName(segmentName))} has been added`);
|
|
45
49
|
log.debug(`Full list of segments: ${this.#segments.map((s) => s.segmentName).join(', ')}`);
|
|
46
50
|
void debouncedEnsureSchemaFiles(this.#projectInfo, schemaOutAbsolutePath, this.#segments.map((s) => s.segmentName));
|
|
@@ -54,14 +58,14 @@ export class VovkDev {
|
|
|
54
58
|
})
|
|
55
59
|
.on('addDir', async (dirPath) => {
|
|
56
60
|
log.debug(`Directory ${dirPath} has been added to segments folder`);
|
|
57
|
-
this.#segments = await locateSegments(apiDirAbsolutePath);
|
|
61
|
+
this.#segments = await locateSegments({ dir: apiDirAbsolutePath, config });
|
|
58
62
|
for (const { segmentName } of this.#segments) {
|
|
59
63
|
void this.#requestSchema(segmentName);
|
|
60
64
|
}
|
|
61
65
|
})
|
|
62
66
|
.on('unlinkDir', async (dirPath) => {
|
|
63
67
|
log.debug(`Directory ${dirPath} has been removed from segments folder`);
|
|
64
|
-
this.#segments = await locateSegments(apiDirAbsolutePath);
|
|
68
|
+
this.#segments = await locateSegments({ dir: apiDirAbsolutePath, config });
|
|
65
69
|
for (const { segmentName } of this.#segments) {
|
|
66
70
|
void this.#requestSchema(segmentName);
|
|
67
71
|
}
|
|
@@ -267,11 +271,11 @@ export class VovkDev {
|
|
|
267
271
|
}
|
|
268
272
|
}
|
|
269
273
|
else if (schema && (!isEmpty(schema.controllers) || !isEmpty(schema.workers))) {
|
|
270
|
-
log.error(`Non-empty schema provided for ${formatLoggedSegmentName(segment.segmentName)} but emitSchema is false`);
|
|
274
|
+
log.error(`Non-empty schema provided for ${formatLoggedSegmentName(segment.segmentName)} but "emitSchema" is false`);
|
|
271
275
|
}
|
|
272
276
|
if (this.#segments.every((s) => this.#schemas[s.segmentName])) {
|
|
273
277
|
log.debug(`All segments with "emitSchema" have schema.`);
|
|
274
|
-
await generateClient(this.#projectInfo, this.#segments, this.#schemas);
|
|
278
|
+
await generateClient({ projectInfo: this.#projectInfo, segments: this.#segments, segmentsSchema: this.#schemas });
|
|
275
279
|
}
|
|
276
280
|
}
|
|
277
281
|
async start() {
|
|
@@ -295,9 +299,9 @@ export class VovkDev {
|
|
|
295
299
|
});
|
|
296
300
|
const apiDirAbsolutePath = path.join(cwd, apiDir);
|
|
297
301
|
const schemaOutAbsolutePath = path.join(cwd, config.schemaOutDir);
|
|
298
|
-
this.#segments = await locateSegments(apiDirAbsolutePath);
|
|
302
|
+
this.#segments = await locateSegments({ dir: apiDirAbsolutePath, config });
|
|
299
303
|
await debouncedEnsureSchemaFiles(this.#projectInfo, schemaOutAbsolutePath, this.#segments.map((s) => s.segmentName));
|
|
300
|
-
// Request schema every segment in 5 seconds in order to update schema
|
|
304
|
+
// Request schema every segment in 5 seconds in order to update schema on start
|
|
301
305
|
setTimeout(() => {
|
|
302
306
|
for (const { segmentName } of this.#segments) {
|
|
303
307
|
const MAX_ATTEMPTS = 3;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { VovkSchema } from 'vovk';
|
|
2
2
|
import type { ProjectInfo } from './getProjectInfo/index.mjs';
|
|
3
3
|
import type { Segment } from './locateSegments.mjs';
|
|
4
|
-
|
|
4
|
+
import { GenerateOptions } from './types.mjs';
|
|
5
|
+
export default function generateClient({ projectInfo, segments, segmentsSchema, templates, prettify: prettifyClient, fullSchema, noClient, }: {
|
|
6
|
+
projectInfo: ProjectInfo;
|
|
7
|
+
segments: Segment[];
|
|
8
|
+
segmentsSchema: Record<string, VovkSchema>;
|
|
9
|
+
} & Pick<GenerateOptions, 'templates' | 'prettify' | 'fullSchema' | 'noClient'>): Promise<{
|
|
5
10
|
written: boolean;
|
|
6
11
|
path: string;
|
|
7
12
|
}>;
|
package/dist/generateClient.mjs
CHANGED
|
@@ -1,59 +1,32 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
|
+
import ejs from 'ejs';
|
|
3
4
|
import formatLoggedSegmentName from './utils/formatLoggedSegmentName.mjs';
|
|
4
5
|
import prettify from './utils/prettify.mjs';
|
|
5
|
-
export default async function generateClient(projectInfo, segments, segmentsSchema) {
|
|
6
|
-
const { config, cwd, log, validateOnClientImportPath, apiEntryPoint, fetcherClientImportPath, schemaOutImportPath } = projectInfo;
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const schema = require('${schemaOutImportPath}');
|
|
23
|
-
`;
|
|
24
|
-
let ts = `// auto-generated
|
|
25
|
-
/* eslint-disable */
|
|
26
|
-
import { clientizeController } from 'vovk/client';
|
|
27
|
-
import { promisifyWorker } from 'vovk/worker';
|
|
28
|
-
import type { VovkClientFetcher } from 'vovk/client';
|
|
29
|
-
import fetcher from '${fetcherClientImportPath}';
|
|
30
|
-
import schema from '${schemaOutImportPath}';
|
|
31
|
-
|
|
32
|
-
`;
|
|
33
|
-
for (let i = 0; i < segments.length; i++) {
|
|
34
|
-
const { routeFilePath, segmentName } = segments[i];
|
|
35
|
-
const schema = segmentsSchema[segmentName];
|
|
36
|
-
if (!schema) {
|
|
37
|
-
throw new Error(`Unable to generate client. No schema found for ${formatLoggedSegmentName(segmentName)}`);
|
|
6
|
+
export default async function generateClient({ projectInfo, segments, segmentsSchema, templates = ['ts', 'compiled'], prettify: prettifyClient, fullSchema, noClient, }) {
|
|
7
|
+
const { config, cwd, log, validateOnClientImportPath, apiEntryPoint, fetcherClientImportPath, schemaOutImportPath, } = projectInfo;
|
|
8
|
+
const __dirname = path.dirname(new URL(import.meta.url).pathname);
|
|
9
|
+
const templatesDir = path.join(__dirname, '..', 'client-templates');
|
|
10
|
+
const clientOutDirAbsolutePath = path.resolve(cwd, config.clientOutDir);
|
|
11
|
+
const mapper = (dir) => (name) => ({
|
|
12
|
+
templatePath: path.resolve(templatesDir, dir, name),
|
|
13
|
+
outPath: path.join(clientOutDirAbsolutePath, name.replace('.ejs', '')),
|
|
14
|
+
});
|
|
15
|
+
const builtInTemplatesMap = {
|
|
16
|
+
ts: ['index.ts.ejs'].map(mapper('ts')),
|
|
17
|
+
compiled: ['client.js.ejs', 'client.d.ts.ejs'].map(mapper('compiled')),
|
|
18
|
+
python: ['__init__.py'].map(mapper('python')),
|
|
19
|
+
};
|
|
20
|
+
const templateFiles = templates.reduce((acc, template) => {
|
|
21
|
+
if (template in builtInTemplatesMap) {
|
|
22
|
+
return [...acc, ...builtInTemplatesMap[template]];
|
|
38
23
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
dts += `
|
|
46
|
-
type Options = typeof fetcher extends VovkClientFetcher<infer U> ? U : never;
|
|
47
|
-
`;
|
|
48
|
-
ts += `
|
|
49
|
-
${validateOnClientImportPath ? `import validateOnClient from '${validateOnClientImportPath}';\n` : '\nconst validateOnClient = undefined;'}
|
|
50
|
-
type Options = typeof fetcher extends VovkClientFetcher<infer U> ? U : never;
|
|
51
|
-
const apiRoot = '${apiEntryPoint}';
|
|
52
|
-
`;
|
|
53
|
-
js += `
|
|
54
|
-
const { default: validateOnClient = null } = ${validateOnClientImportPath ? `require('${validateOnClientImportPath}')` : '{}'};
|
|
55
|
-
const apiRoot = '${apiEntryPoint}';
|
|
56
|
-
`;
|
|
24
|
+
return [...acc, {
|
|
25
|
+
templatePath: path.resolve(cwd, template),
|
|
26
|
+
outPath: path.join(clientOutDirAbsolutePath, path.basename(template).replace('.ejs', ''))
|
|
27
|
+
}];
|
|
28
|
+
}, []);
|
|
29
|
+
// Ensure that each segment has a matching schema if it needs to be emitted:
|
|
57
30
|
for (let i = 0; i < segments.length; i++) {
|
|
58
31
|
const { segmentName } = segments[i];
|
|
59
32
|
const schema = segmentsSchema[segmentName];
|
|
@@ -62,36 +35,57 @@ const apiRoot = '${apiEntryPoint}';
|
|
|
62
35
|
}
|
|
63
36
|
if (!schema.emitSchema)
|
|
64
37
|
continue;
|
|
65
|
-
for (const key of Object.keys(schema.controllers)) {
|
|
66
|
-
dts += `export const ${key}: ReturnType<typeof clientizeController<Controllers${i}["${key}"], Options>>;\n`;
|
|
67
|
-
js += `exports.${key} = clientizeController(schema['${segmentName}'].controllers.${key}, '${segmentName}', { fetcher, validateOnClient, defaultOptions: { apiRoot } });\n`;
|
|
68
|
-
ts += `export const ${key} = clientizeController<Controllers${i}["${key}"], Options>(schema['${segmentName}'].controllers.${key}, '${segmentName}', { fetcher, validateOnClient, defaultOptions: { apiRoot } });\n`;
|
|
69
|
-
}
|
|
70
|
-
for (const key of Object.keys(schema.workers)) {
|
|
71
|
-
dts += `export const ${key}: ReturnType<typeof promisifyWorker<Workers${i}["${key}"]>>;\n`;
|
|
72
|
-
js += `exports.${key} = promisifyWorker(null, schema['${segmentName}'].workers.${key});\n`;
|
|
73
|
-
ts += `export const ${key} = promisifyWorker<Workers${i}["${key}"]>(null, schema['${segmentName}'].workers.${key});\n`;
|
|
74
|
-
}
|
|
75
38
|
}
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
// Data for the EJS templates:
|
|
41
|
+
const ejsData = {
|
|
42
|
+
apiEntryPoint,
|
|
43
|
+
fetcherClientImportPath,
|
|
44
|
+
schemaOutImportPath,
|
|
45
|
+
validateOnClientImportPath,
|
|
46
|
+
segments,
|
|
47
|
+
segmentsSchema,
|
|
48
|
+
};
|
|
49
|
+
// 1. Process each template in parallel
|
|
50
|
+
const processedTemplates = noClient ? [] : await Promise.all(templateFiles.map(async ({ templatePath, outPath }) => {
|
|
51
|
+
// Read the EJS template
|
|
52
|
+
const templateContent = await fs.readFile(templatePath, 'utf-8');
|
|
53
|
+
// Render the template
|
|
54
|
+
let rendered = templatePath.endsWith('.ejs') ? ejs.render(templateContent, ejsData) : templateContent;
|
|
55
|
+
// Optionally prettify
|
|
56
|
+
if (prettifyClient || config.prettifyClient) {
|
|
57
|
+
rendered = await prettify(rendered, outPath);
|
|
58
|
+
}
|
|
59
|
+
// Read existing file content to compare
|
|
60
|
+
const existingContent = await fs.readFile(outPath, 'utf-8').catch(() => '');
|
|
61
|
+
// Determine if we need to rewrite the file
|
|
62
|
+
const needsWriting = existingContent !== rendered;
|
|
63
|
+
return {
|
|
64
|
+
outPath,
|
|
65
|
+
rendered,
|
|
66
|
+
needsWriting,
|
|
67
|
+
};
|
|
68
|
+
}));
|
|
69
|
+
if (fullSchema) {
|
|
70
|
+
const fullSchemaOutAbsolutePath = path.resolve(clientOutDirAbsolutePath, typeof fullSchema === 'string' ? fullSchema : 'full-schema.json');
|
|
71
|
+
await fs.writeFile(fullSchemaOutAbsolutePath, JSON.stringify(segmentsSchema, null, 2));
|
|
72
|
+
log.info(`Full schema written to ${fullSchemaOutAbsolutePath}`);
|
|
86
73
|
}
|
|
87
|
-
|
|
74
|
+
// 2. Check if any file needs rewriting
|
|
75
|
+
const anyNeedsWriting = processedTemplates.some(({ needsWriting }) => needsWriting);
|
|
76
|
+
if (!anyNeedsWriting) {
|
|
88
77
|
log.debug(`Client is up to date and doesn't need to be regenerated (${Date.now() - now}ms)`);
|
|
89
|
-
return { written: false, path:
|
|
78
|
+
return { written: false, path: clientOutDirAbsolutePath };
|
|
90
79
|
}
|
|
91
|
-
|
|
92
|
-
await fs.
|
|
93
|
-
|
|
94
|
-
await
|
|
80
|
+
// 3. Make sure the output directory exists
|
|
81
|
+
await fs.mkdir(clientOutDirAbsolutePath, { recursive: true });
|
|
82
|
+
// 4. Write updated files where needed
|
|
83
|
+
await Promise.all(processedTemplates.map(({ outPath, rendered, needsWriting }) => {
|
|
84
|
+
if (needsWriting) {
|
|
85
|
+
return fs.writeFile(outPath, rendered);
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}));
|
|
95
89
|
log.info(`Client generated in ${Date.now() - now}ms`);
|
|
96
|
-
return { written: true, path:
|
|
90
|
+
return { written: true, path: clientOutDirAbsolutePath };
|
|
97
91
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -63,16 +63,22 @@ program
|
|
|
63
63
|
});
|
|
64
64
|
program
|
|
65
65
|
.command('generate')
|
|
66
|
+
.alias('g')
|
|
66
67
|
.description('Generate client')
|
|
67
|
-
.option('--client-out <path>', 'Path to output directory')
|
|
68
|
+
.option('--out, --client-out-dir <path>', 'Path to output directory')
|
|
69
|
+
.option('--template, --templates <templates...>', 'Client code templates')
|
|
70
|
+
.option('--no-client', 'Do not generate client')
|
|
71
|
+
.option('--full-schema [fileName]', 'Generate client with full schema')
|
|
72
|
+
.option('--prettify', 'Prettify output files')
|
|
68
73
|
.action(async (options) => {
|
|
69
|
-
const
|
|
74
|
+
const { clientOutDir, templates, prettify, noClient, fullSchema } = options;
|
|
75
|
+
const projectInfo = await getProjectInfo({ clientOutDir });
|
|
70
76
|
const { cwd, config, apiDir } = projectInfo;
|
|
71
|
-
const segments = await locateSegments(apiDir);
|
|
77
|
+
const segments = await locateSegments({ dir: apiDir, config });
|
|
72
78
|
const schemaOutAbsolutePath = path.join(cwd, config.schemaOutDir);
|
|
73
79
|
const schemaImportUrl = pathToFileURL(path.join(schemaOutAbsolutePath, 'index.js')).href;
|
|
74
|
-
const
|
|
75
|
-
await generateClient(projectInfo, segments,
|
|
80
|
+
const { default: segmentsSchema } = await import(schemaImportUrl);
|
|
81
|
+
await generateClient({ projectInfo, segments, segmentsSchema, templates, prettify, noClient, fullSchema });
|
|
76
82
|
});
|
|
77
83
|
program
|
|
78
84
|
.command('new [components...]')
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import type { VovkConfig } from './types.mjs';
|
|
1
2
|
export type Segment = {
|
|
2
3
|
routeFilePath: string;
|
|
3
4
|
segmentName: string;
|
|
5
|
+
segmentImportPath: string;
|
|
4
6
|
};
|
|
5
|
-
export default function locateSegments(dir
|
|
7
|
+
export default function locateSegments({ dir, rootDir, config }: {
|
|
8
|
+
dir: string;
|
|
9
|
+
rootDir?: string;
|
|
10
|
+
config: Required<VovkConfig> | null;
|
|
11
|
+
}): Promise<Segment[]>;
|
package/dist/locateSegments.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import getFileSystemEntryType from './utils/getFileSystemEntryType.mjs';
|
|
4
|
-
|
|
4
|
+
// config: null is used for testing
|
|
5
|
+
export default async function locateSegments({ dir, rootDir, config }) {
|
|
5
6
|
let results = [];
|
|
7
|
+
rootDir = rootDir ?? dir;
|
|
6
8
|
// Read the contents of the directory
|
|
7
9
|
const list = await fs.readdir(dir);
|
|
8
10
|
// Iterate through each item in the directory
|
|
@@ -17,11 +19,12 @@ export default async function locateSegments(dir, rootDir = dir) {
|
|
|
17
19
|
if (await getFileSystemEntryType(routeFilePath)) {
|
|
18
20
|
// Calculate the basePath relative to the root directory
|
|
19
21
|
const segmentName = path.relative(rootDir, dir).replace(/\\/g, '/'); // windows fix
|
|
20
|
-
|
|
22
|
+
const segmentImportPath = path.relative(config?.clientOutDir ?? '.__', routeFilePath);
|
|
23
|
+
results.push({ routeFilePath, segmentName, segmentImportPath });
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
26
|
// Recursively search inside subdirectories
|
|
24
|
-
const subDirResults = await locateSegments(filePath, rootDir);
|
|
27
|
+
const subDirResults = await locateSegments({ dir: filePath, rootDir, config });
|
|
25
28
|
results = results.concat(subDirResults);
|
|
26
29
|
}
|
|
27
30
|
}
|
package/dist/new/newModule.mjs
CHANGED
|
@@ -49,7 +49,7 @@ export default async function newModule({ what, moduleNameWithOptionalSegment, d
|
|
|
49
49
|
throw new Error(`Template for "${type}" not found`);
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
-
const segments = await locateSegments(apiDir);
|
|
52
|
+
const segments = await locateSegments({ dir: apiDir, config });
|
|
53
53
|
const segment = segments.find((s) => s.segmentName === segmentName);
|
|
54
54
|
if (!segment) {
|
|
55
55
|
throw new Error(`Unable to create module. Segment "${segmentName}" not found. Run "vovk new segment ${segmentName}" to create it`);
|
package/dist/new/newSegment.mjs
CHANGED
|
@@ -13,6 +13,8 @@ export default async function newSegment({ segmentName, overwrite, dryRun, }) {
|
|
|
13
13
|
}
|
|
14
14
|
const code = await prettify(`import { initVovk } from 'vovk';
|
|
15
15
|
|
|
16
|
+
export const runtime = 'edge';
|
|
17
|
+
|
|
16
18
|
const controllers = {};
|
|
17
19
|
const workers = {};
|
|
18
20
|
|
|
@@ -29,5 +31,5 @@ ${segmentName ? ` segmentName: '${segmentName}',\n` : ''} emitSchema: true,
|
|
|
29
31
|
await fs.mkdir(path.dirname(absoluteSegmentRoutePath), { recursive: true });
|
|
30
32
|
await fs.writeFile(absoluteSegmentRoutePath, code);
|
|
31
33
|
}
|
|
32
|
-
log.info(`${formatLoggedSegmentName(segmentName, { upperFirst: true })} created at ${absoluteSegmentRoutePath}. Run ${chalkHighlightThing(`vovk
|
|
34
|
+
log.info(`${formatLoggedSegmentName(segmentName, { upperFirst: true })} created at ${absoluteSegmentRoutePath}. Run ${chalkHighlightThing(`npx vovk n s c ${[segmentName, 'thing'].filter(Boolean).join('/')}`)} to create a new controller with service.`);
|
|
33
35
|
}
|
package/dist/types.d.mts
CHANGED
|
@@ -48,7 +48,11 @@ export interface DevOptions {
|
|
|
48
48
|
nextDev?: boolean;
|
|
49
49
|
}
|
|
50
50
|
export interface GenerateOptions {
|
|
51
|
-
|
|
51
|
+
clientOutDir?: string;
|
|
52
|
+
templates?: string[];
|
|
53
|
+
prettify?: boolean;
|
|
54
|
+
fullSchema?: string | boolean;
|
|
55
|
+
noClient?: boolean;
|
|
52
56
|
}
|
|
53
57
|
export interface InitOptions {
|
|
54
58
|
yes?: boolean;
|