@nexus-rpc/gen-core 0.1.0-alpha0

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.
@@ -0,0 +1,260 @@
1
+ import {
2
+ ClassType,
3
+ ConvenienceRenderer,
4
+ Name,
5
+ Namer,
6
+ pythonOptions,
7
+ PythonRenderer,
8
+ PythonTargetLanguage,
9
+ Type,
10
+ type LanguageName,
11
+ type OptionValues,
12
+ type RenderContext,
13
+ type RendererOptions,
14
+ type Sourcelike,
15
+ } from "quicktype-core";
16
+ import { RenderAdapter, type RenderAccessible } from "./render-adapter.js";
17
+ import type { PreparedService, PreparedTypeReference } from "./generator.js";
18
+ import { splitDescription } from "./utility.js";
19
+
20
+ // Change some defaults globally
21
+ pythonOptions.justTypes.definition.defaultValue = true;
22
+ pythonOptions.features.definition.defaultValue = "3.7";
23
+ pythonOptions.nicePropertyNames.definition.defaultValue = true;
24
+ pythonOptions.pydanticBaseModel.definition.defaultValue = true;
25
+
26
+ export class PythonLanguageWithNexus extends PythonTargetLanguage {
27
+ protected override makeRenderer<Lang extends LanguageName = "python">(
28
+ renderContext: RenderContext,
29
+ untypedOptionValues: RendererOptions<Lang>,
30
+ ): PythonRenderer {
31
+ const adapter = new PythonRenderAdapter(
32
+ super.makeRenderer(renderContext, untypedOptionValues),
33
+ untypedOptionValues,
34
+ );
35
+ adapter.assertValidOptions();
36
+ return adapter.makeRenderer({
37
+ emitSourceStructure(original, givenOutputFilename) {
38
+ // Generated comment
39
+ adapter.render.emitLine("# Generated by nexus-rpc-gen. DO NOT EDIT!");
40
+ adapter.render.ensureBlankLine();
41
+
42
+ // We need to add the future annotation
43
+ // TODO(cretz): Have option to remove this?
44
+ adapter.render.emitLine("from __future__ import annotations");
45
+ adapter.render.ensureBlankLine();
46
+ original(givenOutputFilename);
47
+ adapter.render.finishFile(adapter.makeFileName());
48
+ },
49
+ emitClosingCode(original) {
50
+ original();
51
+ // Emit services _after_ all types are present. We choose to be after
52
+ // the model types so we don't have any "ForwardRef"s to any models
53
+ // which is not supported by Nexus Python's handler type-checking.
54
+ adapter.emitServices();
55
+ },
56
+ emitClass(_original, t) {
57
+ adapter.emitClass(t);
58
+ },
59
+ emitImports(original) {
60
+ original();
61
+ adapter.emitAdditionalImports();
62
+ },
63
+ });
64
+ }
65
+ }
66
+
67
+ type PythonRenderAccessible = PythonRenderer &
68
+ RenderAccessible & {
69
+ readonly pyOptions: OptionValues<typeof pythonOptions>;
70
+ declareType<T extends Type>(t: T, emitter: () => void): void;
71
+ emitBlock(line: Sourcelike, f: () => void): void;
72
+ emitClass(t: ClassType): void;
73
+ emitClosingCode(): void;
74
+ emitImports(): void;
75
+ emitSourceStructure(givenOutputFilename: string): void;
76
+ pythonType(t: Type, isRootTypeDef: boolean): Sourcelike;
77
+ string(s: string): Sourcelike;
78
+ typeHint(...sl: Sourcelike[]): Sourcelike;
79
+ withImport(module: string, name: string): Sourcelike;
80
+ };
81
+
82
+ const emptyNameMap: ReadonlyMap<Name, string> = new Map();
83
+
84
+ class PythonRenderAdapter extends RenderAdapter<PythonRenderAccessible> {
85
+ private readonly typeNamer: Namer;
86
+ private readonly propertyNamer: Namer;
87
+
88
+ constructor(render: ConvenienceRenderer, rendererOptions: RendererOptions) {
89
+ super(render, rendererOptions);
90
+ this.typeNamer = this.render.makeNamedTypeNamer();
91
+ this.propertyNamer = this.render.namerForObjectProperty();
92
+ }
93
+
94
+ assertValidOptions() {
95
+ // Currently, we only support specific Python options
96
+ if (!this.render.pyOptions.justTypes) {
97
+ throw new Error("Python option for just-types must be set");
98
+ }
99
+ if (!this.render.pyOptions.pydanticBaseModel) {
100
+ throw new Error("Python option for pydantic-model must be set");
101
+ }
102
+ if (!this.render.pyOptions.features.typeHints) {
103
+ throw new Error("Python option to include type hints must be set");
104
+ }
105
+ }
106
+
107
+ makeFileName() {
108
+ // If there is a single service, use that, otherwise use the
109
+ // filename sans extensions to build it
110
+ const services = Object.entries(this.schema.services);
111
+ const name =
112
+ services.length == 1
113
+ ? services[0][0]
114
+ : this.nexusRendererOptions.firstFilenameSansExtensions;
115
+ return this.propertyNamer.nameStyle(name) + ".py";
116
+ }
117
+
118
+ emitAdditionalImports() {
119
+ // We have to emit imports for existing types with dots. Unlike the existing
120
+ // withImport/emitImports, we do not want from X import Y, we want just
121
+ // import and we will fully-qualify the types at the usage site.
122
+ const toImport: string[] = [];
123
+ for (const svcSchema of Object.values(this.schema.services)) {
124
+ for (const opSchema of Object.values(svcSchema.operations)) {
125
+ for (const type of [opSchema.input, opSchema.output]) {
126
+ if (type?.kind == "existing") {
127
+ const lastDot = type.name.lastIndexOf(".");
128
+ if (
129
+ lastDot >= 0 &&
130
+ !toImport.includes(type.name.slice(0, lastDot))
131
+ ) {
132
+ toImport.push(type.name.slice(0, lastDot));
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ toImport.sort();
139
+ for (const module_ of toImport) {
140
+ this.render.emitLine("import ", module_);
141
+ }
142
+ }
143
+
144
+ emitServices() {
145
+ for (const [serviceName, serviceSchema] of Object.entries(
146
+ this.schema.services,
147
+ )) {
148
+ this.emitService(serviceName, serviceSchema);
149
+ }
150
+ }
151
+
152
+ emitService(serviceName: string, serviceSchema: PreparedService) {
153
+ this.render.ensureBlankLine(2);
154
+ const typeName = this.makeServiceTypeName(
155
+ this.typeNamer.nameStyle(serviceName),
156
+ );
157
+ // Decorator
158
+ this.render.emitLine(
159
+ "@",
160
+ this.render.withImport("nexusrpc", "service"),
161
+ serviceName == typeName
162
+ ? []
163
+ : ["(name=", this.render.string(serviceName), ")"],
164
+ );
165
+ // Service class with each property
166
+ this.render.emitBlock(["class ", typeName, ":"], () => {
167
+ this.render.emitDescription(splitDescription(serviceSchema.description));
168
+ if (Object.entries(serviceSchema.operations).length == 0) {
169
+ this.render.emitLine("pass");
170
+ }
171
+ const propertyNamesInUse = {};
172
+ this.render.forEachWithBlankLines(
173
+ Object.entries(serviceSchema.operations),
174
+ "interposing",
175
+ (op, opName, pos) => {
176
+ const propertyName = this.makeOperationFunctionName(
177
+ this.propertyNamer.nameStyle(opName),
178
+ propertyNamesInUse,
179
+ );
180
+ this.render.emitLine(
181
+ propertyName,
182
+ ": ",
183
+ this.render.withImport("nexusrpc", "Operation"),
184
+ "[",
185
+ this.getNexusType(op.input) ?? "None",
186
+ ", ",
187
+ this.getNexusType(op.output) ?? "None",
188
+ "]",
189
+ opName == propertyName
190
+ ? []
191
+ : [
192
+ " = ",
193
+ this.render.withImport("nexusrpc", "Operation"),
194
+ "(name=",
195
+ this.render.string(opName),
196
+ ")",
197
+ ],
198
+ );
199
+ this.render.emitDescription(splitDescription(op.description));
200
+ },
201
+ );
202
+ });
203
+ }
204
+
205
+ emitClass(t: ClassType) {
206
+ this.render.declareType(t, () => {
207
+ if (t.getProperties().size === 0) {
208
+ this.render.emitLine("pass");
209
+ return;
210
+ }
211
+ this.render.forEachClassProperty(t, "none", (name, jsonName, cp) => {
212
+ // Get the type string and append a pydantic Field to it if the name
213
+ // doesn't match the JSON name.
214
+ let typeSource = this.render.pythonType(cp.type, true);
215
+ // const fieldName = name.namingFunction.nameStyle(name.firstProposedName(emptyNameMap));
216
+ const fieldName = this.render.sourcelikeToString(name);
217
+ if (fieldName != jsonName) {
218
+ // Ellipsis means no default. In optional situations, typeSource has a
219
+ // trailing " = None" which we need to remove and set as default
220
+ let fieldDefault = "...";
221
+ if (Array.isArray(typeSource) && typeSource.at(-1) == " = None") {
222
+ typeSource = typeSource.slice(0, -1);
223
+ fieldDefault = "None";
224
+ }
225
+ typeSource = [
226
+ typeSource,
227
+ " = ",
228
+ this.render.withImport("pydantic", "Field"),
229
+ "(",
230
+ fieldDefault,
231
+ ", serialization_alias=",
232
+ this.render.string(jsonName),
233
+ ")",
234
+ ];
235
+ }
236
+ this.render.emitLine(name, ": ", typeSource);
237
+ this.render.emitDescription(
238
+ this.render.descriptionForClassProperty(t, jsonName),
239
+ );
240
+ });
241
+ this.render.ensureBlankLine();
242
+ });
243
+ }
244
+
245
+ getNexusType(
246
+ reference: PreparedTypeReference | undefined,
247
+ ): Sourcelike | undefined {
248
+ if (!reference) {
249
+ return undefined;
250
+ } else if (reference.kind == "existing") {
251
+ return reference.name;
252
+ } else {
253
+ const type = this.render.topLevels.get(reference.name);
254
+ if (!type) {
255
+ throw new Error(`Unable to find type for ${reference.name}`);
256
+ }
257
+ return this.render.pythonType(type, false);
258
+ }
259
+ }
260
+ }
@@ -0,0 +1,288 @@
1
+ import {
2
+ ClassType,
3
+ Name,
4
+ tsFlowOptions,
5
+ Type,
6
+ TypeScriptRenderer,
7
+ TypeScriptTargetLanguage,
8
+ type LanguageName,
9
+ type MultiWord,
10
+ type RenderContext,
11
+ type RendererOptions,
12
+ type Sourcelike,
13
+ } from "quicktype-core";
14
+ import { RenderAdapter, type RenderAccessible } from "./render-adapter.js";
15
+ import type { PreparedService, PreparedTypeReference } from "./generator.js";
16
+ import { splitDescription } from "./utility.js";
17
+ import { utf16StringEscape } from "quicktype-core/dist/support/Strings.js";
18
+
19
+ // Change some defaults globally
20
+ tsFlowOptions.justTypes.definition.defaultValue = true;
21
+
22
+ export class TypeScriptLanguageWithNexus extends TypeScriptTargetLanguage {
23
+ protected override get defaultIndentation(): string {
24
+ // We want two-space indent to be default for TypeScript
25
+ return " ";
26
+ }
27
+
28
+ protected override makeRenderer<Lang extends LanguageName = "typescript">(
29
+ renderContext: RenderContext,
30
+ untypedOptionValues: RendererOptions<Lang>,
31
+ ): TypeScriptRenderer {
32
+ const adapter = new TypeScriptRenderAdapter(
33
+ super.makeRenderer(renderContext, untypedOptionValues),
34
+ untypedOptionValues,
35
+ );
36
+ return adapter.makeRenderer({
37
+ emitSourceStructure(original) {
38
+ adapter.emitServices();
39
+ original();
40
+ adapter.render.finishFile(adapter.makeFileName());
41
+ },
42
+ emitTypes(original) {
43
+ // We cannot use original emitTypes, see override for reason why
44
+ adapter.emitTypes();
45
+ },
46
+ });
47
+ }
48
+ }
49
+
50
+ type TypeScriptRenderAccessible = TypeScriptRenderer &
51
+ RenderAccessible & {
52
+ emitBlock(source: Sourcelike, end: Sourcelike, emit: () => void): void;
53
+ emitSourceStructure(): void;
54
+ emitTypes(): void;
55
+ nameStyle(original: string, upper: boolean): string;
56
+ sourceFor(t: Type): MultiWord;
57
+ };
58
+
59
+ interface ExistingType {
60
+ type: string;
61
+ from?: string;
62
+ alias?: string;
63
+ }
64
+
65
+ class TypeScriptRenderAdapter extends RenderAdapter<TypeScriptRenderAccessible> {
66
+ private _existingTypes?: Record<string, ExistingType>;
67
+
68
+ makeFileName() {
69
+ // If there is a single service, use that, otherwise use the
70
+ // filename sans extensions to build it
71
+ const services = Object.entries(this.schema.services);
72
+ if (services.length == 1) {
73
+ return `${services[0][0]}.ts`;
74
+ }
75
+ return `${this.nexusRendererOptions.firstFilenameSansExtensions}.ts`;
76
+ }
77
+
78
+ // Key is full string as given in schema
79
+ get existingTypes(): Record<string, ExistingType> {
80
+ if (this._existingTypes === undefined) {
81
+ this._existingTypes = {};
82
+ const inUse = {};
83
+ for (const serviceSchema of Object.values(this.schema.services)) {
84
+ for (const opSchema of Object.values(serviceSchema.operations)) {
85
+ for (const type of [opSchema.input, opSchema.output]) {
86
+ if (type?.kind == "existing") {
87
+ // TODO(cretz): Generics with qualified type args that need to be imported?
88
+ const lastHash = type.name.lastIndexOf("#");
89
+ const existingType: ExistingType = {
90
+ type: type.name.slice(lastHash + 1),
91
+ from: lastHash == -1 ? undefined : type.name.slice(0, lastHash),
92
+ };
93
+ // Alias it while it already exists
94
+ let alias = existingType.type;
95
+ for (
96
+ let index = 1;
97
+ Object.hasOwn(inUse, alias) ||
98
+ this.topLevelNameInUse(alias, true);
99
+ index++
100
+ ) {
101
+ alias = `${existingType.type}${index}`;
102
+ }
103
+ if (alias != existingType.type) {
104
+ existingType.alias = alias;
105
+ }
106
+ this._existingTypes[type.name] = existingType;
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ return this._existingTypes;
113
+ }
114
+
115
+ emitServices() {
116
+ // If there are no services, do nothing
117
+ if (Object.entries(this.schema.services).length == 0) {
118
+ return;
119
+ }
120
+
121
+ // Generated comment
122
+ this.render.emitLine("// Generated by nexus-rpc-gen. DO NOT EDIT!");
123
+ this.render.ensureBlankLine();
124
+
125
+ // Import Nexus
126
+ this.render.emitLine('import * as nexus from "nexus-rpc";');
127
+
128
+ // Import all "existing" types
129
+ const existingTypesByFrom: Record<string, ExistingType[]> = {};
130
+ for (const existingType of Object.values(this.existingTypes)) {
131
+ if (existingType.from) {
132
+ if (!Object.hasOwn(existingTypesByFrom, existingType.from)) {
133
+ existingTypesByFrom[existingType.from] = [];
134
+ }
135
+ existingTypesByFrom[existingType.from].push(existingType);
136
+ }
137
+ }
138
+ // TODO(cretz): Better sorting?
139
+ for (const [from, existingTypes] of Object.entries(
140
+ existingTypesByFrom,
141
+ ).toSorted((a, b) => a[0].localeCompare(b[0]))) {
142
+ existingTypes.sort((a, b) => a.type.localeCompare(b.type));
143
+ const pieces = existingTypes.map((t) => {
144
+ let piece = `type ${t.type}`;
145
+ if (t.alias) piece += ` as ${t.alias}`;
146
+ return piece;
147
+ });
148
+ this.render.emitLine(
149
+ "import { ",
150
+ pieces.join(", "),
151
+ ' } from "',
152
+ utf16StringEscape(from),
153
+ '";',
154
+ );
155
+ }
156
+
157
+ // Emit each service
158
+ for (const [serviceName, serviceSchema] of Object.entries(
159
+ this.schema.services,
160
+ )) {
161
+ this.emitService(serviceName, serviceSchema);
162
+ }
163
+ }
164
+
165
+ emitService(serviceName: string, serviceSchema: PreparedService) {
166
+ this.render.ensureBlankLine();
167
+ const constName = this.makeServiceTypeName(
168
+ this.render.nameStyle(serviceName, false),
169
+ );
170
+
171
+ this.render.emitDescription(splitDescription(serviceSchema.description));
172
+ this.render.emitBlock(
173
+ [
174
+ "export const ",
175
+ constName,
176
+ ' = nexus.service("',
177
+ utf16StringEscape(serviceName),
178
+ '", ',
179
+ ],
180
+ ");",
181
+ () => {
182
+ const propertyNamesInUse = {};
183
+ this.render.forEachWithBlankLines(
184
+ Object.entries(serviceSchema.operations),
185
+ "interposing",
186
+ (op, opName, pos) => {
187
+ this.render.emitDescription(splitDescription(op.description));
188
+ const propertyName = this.makeOperationFunctionName(
189
+ this.render.nameStyle(opName, false),
190
+ propertyNamesInUse,
191
+ );
192
+ const opArguments =
193
+ opName == propertyName
194
+ ? []
195
+ : ['{ name: "', utf16StringEscape(opName), '" }'];
196
+ this.render.emitLine(
197
+ propertyName,
198
+ ": nexus.operation<",
199
+ this.getNexusType(op.input) ?? "void",
200
+ ", ",
201
+ this.getNexusType(op.output) ?? "void",
202
+ ">(",
203
+ opArguments,
204
+ "),",
205
+ );
206
+ },
207
+ );
208
+ },
209
+ );
210
+ }
211
+
212
+ emitTypes() {
213
+ // We cannot use original emitTypes because it renders all top level types
214
+ // as non-export and includes __ALL_TYPES__, but we want export and don't
215
+ // want that pseudo type. So we copy what Quicktype did mostly, with some
216
+ // alterations.
217
+
218
+ // Primitives
219
+ this.render.forEachWithBlankLines(
220
+ this.render.topLevels,
221
+ "none",
222
+ (t, name, pos) => {
223
+ if (!t.isPrimitive() || name == "__ALL_TYPES__") {
224
+ return;
225
+ }
226
+
227
+ this.render.ensureBlankLine();
228
+ this.render.emitDescription(this.render.descriptionForType(t));
229
+ this.render.emitLine(
230
+ "export type ",
231
+ name,
232
+ " = ",
233
+ this.render.sourceFor(t).source,
234
+ ";",
235
+ );
236
+ },
237
+ );
238
+
239
+ // Named types
240
+ this.render.forEachNamedType(
241
+ "leading-and-interposing",
242
+ (c: ClassType, n: Name) => (this.render as any).emitClass(c, n),
243
+ (enm, n) => (this.render as any).emitEnum(enm, n),
244
+ (u, n) => (this.render as any).emitUnion(u, n),
245
+ );
246
+ }
247
+
248
+ exportTopLevelPrimitives() {
249
+ // TypeScript rendered by default does not export primitive type aliases, so
250
+ // we must. First, collect set to export
251
+ const toExport = Object.keys(this.schema.topLevelJsonSchemaTypes).filter(
252
+ (name) => this.render.topLevels.get(name)?.isPrimitive(),
253
+ );
254
+ // Render if any
255
+ if (toExport.length > 0) {
256
+ this.render.ensureBlankLine();
257
+ this.render.emitLine("export { ", toExport.join(", "), " };");
258
+ }
259
+ }
260
+
261
+ getNexusType(
262
+ reference: PreparedTypeReference | undefined,
263
+ ): Sourcelike | undefined {
264
+ if (!reference) {
265
+ return undefined;
266
+ } else if (reference.kind == "existing") {
267
+ const type = this.existingTypes[reference.name];
268
+ return type.alias ?? type.type;
269
+ } else {
270
+ const type = this.render.topLevels.get(reference.name);
271
+ if (!type) {
272
+ throw new Error(`Unable to find type for ${reference.name}`);
273
+ }
274
+
275
+ // If the type is primitive, use the alias
276
+ if (type.isPrimitive()) {
277
+ return reference.name;
278
+ }
279
+
280
+ return this.render.sourceFor(type).source;
281
+ }
282
+ }
283
+
284
+ opNameForbidden(name: string): boolean {
285
+ // We also want to forbid any Object functions
286
+ return super.opNameForbidden(name) || name in {};
287
+ }
288
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { DefinitionSchema } from "./definition-schema.js";
2
+
3
+ import jsonSchema from "../../../../schemas/nexus-rpc-gen.json" with { type: "json" };
4
+ import Ajv from "ajv";
5
+ import addFormats from "ajv-formats";
6
+ import yaml from "yaml";
7
+ import { readFile } from "node:fs/promises";
8
+
9
+ const ajv = new Ajv({
10
+ allErrors: true,
11
+ strict: false,
12
+ loadSchema: async (uri) => {
13
+ // TODO(cretz): This
14
+ throw new Error(`Unable to load remote schema at ${uri} at this time`);
15
+ },
16
+ });
17
+
18
+ addFormats(ajv);
19
+
20
+ export async function parseFiles(files: string[]): Promise<DefinitionSchema> {
21
+ // TODO(cretz): Multi-file
22
+ if (files.length != 1) {
23
+ throw new Error("Must have only 1 file at this time");
24
+ }
25
+
26
+ // Parse YAML
27
+ const document = yaml.parse(await readFile(files[0], "utf8"));
28
+
29
+ // Validate. We recreate the validator because it carries state.
30
+ const valueFunction = await ajv.compileAsync<DefinitionSchema>(jsonSchema);
31
+ if (!valueFunction(document)) {
32
+ for (const error of valueFunction.errors ?? []) {
33
+ console.log(error);
34
+ }
35
+ throw new Error(
36
+ `Found ${valueFunction.errors?.length} error(s): ` +
37
+ valueFunction.errors
38
+ ?.map(
39
+ (error) =>
40
+ `${error.instancePath || "(root)"}: ${error.message} (${JSON.stringify(error.params)})`,
41
+ )
42
+ .join(", "),
43
+ );
44
+ }
45
+
46
+ return document;
47
+ }