[http-server-javascript] Merge JavaScript Server Generator to Main (#3231)
This work-in-progress PR tracks merging the JavaScript server code generator to the TypeSpec repository. The JavaScript server code generator creates HTTP bindings for TypeSpec HTTP services and exposes them for binding either to the Node.js http server directly, or to an Express.js app as middleware. Closes #3215 --------- Co-authored-by: Will Temple <will@wtemple.net>
This commit is contained in:
Родитель
86a0c564b9
Коммит
b5d766cd43
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
changeKind: internal
|
||||
packages:
|
||||
- "@typespec/http-server-javascript"
|
||||
---
|
||||
|
||||
Added the experimental HTTP server generator for JavaScript.
|
|
@ -12,6 +12,7 @@ words:
|
|||
- azsdkengsys
|
||||
- azurecr
|
||||
- azuresdk
|
||||
- bifilter
|
||||
- blockful
|
||||
- blockless
|
||||
- cadl
|
||||
|
@ -28,6 +29,7 @@ words:
|
|||
- CRUDL
|
||||
- dbaeumer
|
||||
- debouncer
|
||||
- destructures
|
||||
- devdiv
|
||||
- Diagnoser
|
||||
- dogfood
|
||||
|
@ -84,6 +86,7 @@ words:
|
|||
- protoc
|
||||
- psscriptanalyzer
|
||||
- pwsh
|
||||
- recase
|
||||
- regen
|
||||
- respecify
|
||||
- rpaas
|
||||
|
@ -109,11 +112,14 @@ words:
|
|||
- uitestresults
|
||||
- unassignable
|
||||
- Uncapitalize
|
||||
- undifferentiable
|
||||
- uncollapsed
|
||||
- uninstantiated
|
||||
- unioned
|
||||
- unparented
|
||||
- unprefixed
|
||||
- unprojected
|
||||
- unrepresentable
|
||||
- unsourced
|
||||
- unversioned
|
||||
- VITE
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
title: "Emitter usage"
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 3
|
||||
---
|
||||
|
||||
# Emitter
|
||||
|
||||
## Usage
|
||||
|
||||
1. Via the command line
|
||||
|
||||
```bash
|
||||
tsp compile . --emit=@typespec/http-server-javascript
|
||||
```
|
||||
|
||||
2. Via the config
|
||||
|
||||
```yaml
|
||||
emit:
|
||||
- "@typespec/http-server-javascript"
|
||||
```
|
||||
|
||||
## Emitter options
|
||||
|
||||
### `features`
|
||||
|
||||
**Type:** `object`
|
||||
|
||||
### `omit-unreachable-types`
|
||||
|
||||
**Type:** `boolean`
|
||||
|
||||
### `no-format`
|
||||
|
||||
**Type:** `boolean`
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
title: Overview
|
||||
sidebar_position: 0
|
||||
toc_min_heading_level: 2
|
||||
toc_max_heading_level: 3
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
|
||||
# Overview
|
||||
|
||||
TypeSpec HTTP server code generator for JavaScript
|
||||
|
||||
## Install
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="spec" label="In a spec" default>
|
||||
|
||||
```bash
|
||||
npm install @typespec/http-server-javascript
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="library" label="In a library" default>
|
||||
|
||||
```bash
|
||||
npm install --save-peer @typespec/http-server-javascript
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Emitter usage
|
||||
|
||||
[See documentation](./emitter.md)
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
|
@ -0,0 +1,183 @@
|
|||
# @typespec/http-server-javascript
|
||||
|
||||
:warning: **This package is highly experimental and may be subject to breaking changes and bugs.** Please expect that your code may need to be updated as this package evolves, and please report any issues you encounter.
|
||||
|
||||
TypeSpec HTTP server code generator for JavaScript and TypeScript.
|
||||
|
||||
This package generates an implementation of an HTTP server layer for a TypeSpec API. It supports binding directly to a
|
||||
Node.js HTTP server or Express.js application.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @typespec/http-server-javascript
|
||||
```
|
||||
|
||||
## Emitter
|
||||
|
||||
### Usage
|
||||
|
||||
1. Via the command line
|
||||
|
||||
```bash
|
||||
tsp compile . --emit=@typespec/http-server-javascript
|
||||
```
|
||||
|
||||
2. Via the config
|
||||
|
||||
```yaml
|
||||
emit:
|
||||
- "@typespec/http-server-javascript"
|
||||
```
|
||||
|
||||
### Emitter options
|
||||
|
||||
#### `express`
|
||||
|
||||
**Type:** `boolean`
|
||||
|
||||
If set to `true`, the emitter will generate a router that exposes an Express.js middleware function in addition to the
|
||||
ordinary Node.js HTTP server router.
|
||||
|
||||
If this option is not set to `true`, the `expressMiddleware` property will not be present on the generated router.
|
||||
|
||||
#### `omit-unreachable-types`
|
||||
|
||||
**Type:** `boolean`
|
||||
|
||||
By default, the emitter will create interfaces that represent all models in the service namespace. If this option is set
|
||||
to `true`, the emitter will only emit those types that are reachable from an HTTP operation.
|
||||
|
||||
#### `no-format`
|
||||
|
||||
**Type:** `boolean`
|
||||
|
||||
If set to `true`, the emitter will not format the generated code using Prettier.
|
||||
|
||||
## Functionality and generated code
|
||||
|
||||
The emitter generates a few major components:
|
||||
|
||||
### Router
|
||||
|
||||
The highest-level component that your code interacts with directly is the router implementation.
|
||||
`@typespec/http-server-javascript` generates a static router that you can bind to an implementation of an HTTP server.
|
||||
|
||||
The router is generated in the `http/router.js` module within the output directory. Each service will have its own
|
||||
router implementation named after the service. For example, given a service namespace named `Todo`, the router module
|
||||
will export a function `createTodoRouter`. This function creates an instance of a router that dispatches methods within
|
||||
the `Todo` service.
|
||||
|
||||
```ts
|
||||
import { createTodoRouter } from "../tsp-output/@typespec/http-server-javascript/http/router.js";
|
||||
|
||||
const router = createTodoRouter(users, todoItems, attachments);
|
||||
```
|
||||
|
||||
As arguments, the `createTodoRouter` function expects implementations of the underlying service interfaces. These
|
||||
interfaces are explained further in the next section.
|
||||
|
||||
Once the router is created, it is bound to an instance of the HTTP server. The router's `dispatch` method implements the
|
||||
Node.js event handler signature for the `request` event on a Node.js HTTP server.
|
||||
|
||||
```ts
|
||||
const server = http.createServer();
|
||||
|
||||
server.on("request", router.dispatch);
|
||||
|
||||
server.listen(8080, () => {
|
||||
console.log("Server listening on http://localhost:8080");
|
||||
});
|
||||
```
|
||||
|
||||
Alternatively, the router can be used with Express.js instead of the Node.js HTTP server directly. If the `express`
|
||||
feature is enabled in the emitter options, the router will expose an `expressMiddleware` property that implements the
|
||||
Express.js middleware interface.
|
||||
|
||||
```ts
|
||||
import express from "express";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(router.expressMiddleware);
|
||||
|
||||
app.listen(8080, () => {
|
||||
console.log("Server listening on http://localhost:8080");
|
||||
});
|
||||
```
|
||||
|
||||
### Service interfaces
|
||||
|
||||
The emitter generates interfaces for each collection of service methods that exists in the service namespace.
|
||||
Implementations of these interfaces are required to instantiate the router. When the router processes an HTTP request,
|
||||
it will call the appropriate method on the service implementation after determining the route and method.
|
||||
|
||||
For example, given the following TypeSpec namespace `Users` within the `Todo` service:
|
||||
|
||||
```tsp
|
||||
namespace Users {
|
||||
@route("/users")
|
||||
@post
|
||||
op create(
|
||||
user: User,
|
||||
): WithStandardErrors<UserCreatedResponse | UserExistsResponse | InvalidUserResponse>;
|
||||
}
|
||||
```
|
||||
|
||||
The emitter will generate a corresponding interface `Users` within the module `models/all/todo/index.js` in the output
|
||||
directory.
|
||||
|
||||
```ts
|
||||
/** An interface representing the operations defined in the 'Todo.Users' namespace. */
|
||||
export interface Users<Context = unknown> {
|
||||
create(
|
||||
ctx: Context,
|
||||
user: User
|
||||
): Promise<
|
||||
| UserCreatedResponse
|
||||
| UserExistsResponse
|
||||
| InvalidUserResponse
|
||||
| Standard4XxResponse
|
||||
| Standard5XxResponse
|
||||
>;
|
||||
}
|
||||
```
|
||||
|
||||
An object implementing this `Users` interface must be passed to the router when it is created. The `Context` type
|
||||
parameter represents the underlying protocol or framework-specific context that the service implementation may inspect.
|
||||
If you need to access the HTTP request or response objects directly in the implementation of the service methods, you
|
||||
must use the `HttpContext` type as the `Context` argument when implementing the service interface. Otherwise, it is safe
|
||||
to use the default `unknown` argument.
|
||||
|
||||
```ts
|
||||
import { HttpContext } from "../tsp-output/@typespec/http-server-javascript/helpers/router.js";
|
||||
import { Users } from "../tsp-output/@typespec/http-server-javascript/models/all/todo/index.js";
|
||||
|
||||
export const users: Users<HttpContext> = {
|
||||
async create(ctx, user) {
|
||||
// Implementation
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Models
|
||||
|
||||
The emitter generates TypeScript interfaces that represent the model types used in the service operations. This allows
|
||||
the service implementation to interact with the data structures carried over the HTTP protocol in a type-safe manner.
|
||||
|
||||
### Operation functions
|
||||
|
||||
While your code should never need to interact with these functions directly, the emitter generates a function per HTTP
|
||||
operation that handles the parsing and validation of the request contents. This allows the service implementation to be
|
||||
written in terms of ordinary TypeScript types and values rather than raw HTTP request and response objects. In general:
|
||||
|
||||
- The Node.js HTTP server or Express.js application (your code) calls the router (generated code), which determines
|
||||
which service operation function (generated code) to call based on the route, method, and other HTTP metadata in the
|
||||
case of shared routes.
|
||||
- The operation function (generated code) deserializes the request body, query parameters, and headers into TypeScript
|
||||
types, and may perform request validation.
|
||||
- The operation function (generated code) calls the service implementation (your code) with the deserialized request
|
||||
data.
|
||||
- The service implementation (your code) returns a result or throws an error.
|
||||
- The operation function (generated code) responds to the HTTP request on your behalf, converting the result or error
|
||||
into HTTP response data.
|
|
@ -0,0 +1,170 @@
|
|||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
/* eslint no-console: "off" */
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const HELPER_DECLARATION_PATH = path.resolve("generated-defs", "helpers");
|
||||
const HELPER_SRC_PATH = path.resolve("src", "helpers");
|
||||
|
||||
console.log("Building JS server generator helpers.");
|
||||
|
||||
async function* visitAllFiles(base: string): AsyncIterable<string> {
|
||||
const contents = await fs.readdir(base, { withFileTypes: true });
|
||||
|
||||
for (const entry of contents) {
|
||||
if (entry.isDirectory()) {
|
||||
yield* visitAllFiles(path.join(base, entry.name));
|
||||
} else if (entry.isFile()) {
|
||||
yield path.join(base, entry.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const allFiles: string[] = [];
|
||||
const indices = new Map<string, string[]>();
|
||||
|
||||
const ctxPath = path.resolve("src", "ctx.js");
|
||||
|
||||
await fs.rm(HELPER_DECLARATION_PATH, { recursive: true, force: true });
|
||||
|
||||
function addIndex(dir: string, file: string) {
|
||||
const index = indices.get(dir);
|
||||
|
||||
if (index) {
|
||||
index.push(file);
|
||||
} else {
|
||||
indices.set(dir, [file]);
|
||||
}
|
||||
}
|
||||
|
||||
for await (const file of visitAllFiles(HELPER_SRC_PATH)) {
|
||||
allFiles.push(file);
|
||||
addIndex(path.dirname(file), file);
|
||||
}
|
||||
|
||||
for (const file of allFiles) {
|
||||
if (!file.endsWith(".ts")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(HELPER_SRC_PATH, file);
|
||||
|
||||
console.log("Building helper:", relativePath);
|
||||
|
||||
const targetPath = path.resolve(HELPER_DECLARATION_PATH, relativePath);
|
||||
|
||||
const targetDir = path.dirname(targetPath);
|
||||
const targetFileBase = path.basename(targetPath, ".ts");
|
||||
const isIndex = targetFileBase === "index";
|
||||
const targetBase = isIndex ? path.basename(targetDir) : targetFileBase;
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const childModules = isIndex ? indices.get(path.dirname(file)) : [];
|
||||
|
||||
if (isIndex) {
|
||||
indices.delete(path.dirname(file));
|
||||
}
|
||||
|
||||
const contents = await fs.readFile(file, "utf-8");
|
||||
|
||||
let childModuleLines =
|
||||
childModules
|
||||
?.filter((m) => path.basename(m, ".ts") !== "index")
|
||||
.map((child) => {
|
||||
const childBase = path.basename(child, ".ts");
|
||||
return ` await import("./${childBase}.js").then((m) => m.createModule(module));`;
|
||||
}) ?? [];
|
||||
|
||||
if (childModuleLines.length > 0) {
|
||||
childModuleLines = [" // Child modules", ...childModuleLines, ""];
|
||||
}
|
||||
|
||||
const transformed = [
|
||||
"// Copyright (c) Microsoft Corporation",
|
||||
"// Licensed under the MIT license.",
|
||||
"",
|
||||
`import { Module } from "${path.relative(targetDir, ctxPath).replace(/\\/g, "/")}";`,
|
||||
"",
|
||||
"export let module: Module = undefined as any;",
|
||||
"",
|
||||
"// prettier-ignore",
|
||||
"const lines = [",
|
||||
...contents.split(/\r?\n/).map((line) => " " + JSON.stringify(line) + ","),
|
||||
"];",
|
||||
"",
|
||||
"export async function createModule(parent: Module): Promise<Module> {",
|
||||
" if (module) return module;",
|
||||
"",
|
||||
" module = {",
|
||||
` name: ${JSON.stringify(targetBase)},`,
|
||||
` cursor: parent.cursor.enter(${JSON.stringify(targetBase)}),`,
|
||||
" imports: [],",
|
||||
" declarations: [],",
|
||||
" };",
|
||||
"",
|
||||
...childModuleLines,
|
||||
" module.declarations.push(lines);",
|
||||
"",
|
||||
" parent.declarations.push(module);",
|
||||
"",
|
||||
" return module;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
await fs.writeFile(targetPath, transformed);
|
||||
}
|
||||
|
||||
console.log("Building index files.");
|
||||
|
||||
for (const [dir, files] of indices.entries()) {
|
||||
console.log("Building index:", dir);
|
||||
|
||||
const relativePath = path.relative(HELPER_SRC_PATH, dir);
|
||||
|
||||
const targetPath = path.resolve(HELPER_DECLARATION_PATH, relativePath, "index.ts");
|
||||
|
||||
const children = files.map((file) => {
|
||||
return ` await import("./${path.basename(file, ".ts")}.js").then((m) => m.createModule(module));`;
|
||||
});
|
||||
|
||||
const transformed = [
|
||||
"// Copyright (c) Microsoft Corporation",
|
||||
"// Licensed under the MIT license.",
|
||||
"",
|
||||
`import { Module } from "${path.relative(path.dirname(targetPath), ctxPath).replace(/\\/g, "/")}";`,
|
||||
"",
|
||||
"export let module: Module = undefined as any;",
|
||||
"",
|
||||
"export async function createModule(parent: Module): Promise<Module> {",
|
||||
" if (module) return module;",
|
||||
"",
|
||||
" module = {",
|
||||
` name: ${JSON.stringify(path.basename(dir))},`,
|
||||
` cursor: parent.cursor.enter(${JSON.stringify(path.basename(dir))}),`,
|
||||
" imports: [],",
|
||||
" declarations: [],",
|
||||
" };",
|
||||
"",
|
||||
" // Child modules",
|
||||
...children,
|
||||
"",
|
||||
" parent.declarations.push(module);",
|
||||
"",
|
||||
" return module;",
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
await fs.writeFile(targetPath, transformed);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Module } from "../../src/ctx.js";
|
||||
|
||||
export let module: Module = undefined as any;
|
||||
|
||||
export async function createModule(parent: Module): Promise<Module> {
|
||||
if (module) return module;
|
||||
|
||||
module = {
|
||||
name: "helpers",
|
||||
cursor: parent.cursor.enter("helpers"),
|
||||
imports: [],
|
||||
declarations: [],
|
||||
};
|
||||
|
||||
// Child modules
|
||||
await import("./router.js").then((m) => m.createModule(module));
|
||||
|
||||
parent.declarations.push(module);
|
||||
|
||||
return module;
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Module } from "../../src/ctx.js";
|
||||
|
||||
export let module: Module = undefined as any;
|
||||
|
||||
// prettier-ignore
|
||||
const lines = [
|
||||
"// Copyright (c) Microsoft Corporation",
|
||||
"// Licensed under the MIT license.",
|
||||
"",
|
||||
"import type * as http from \"node:http\";",
|
||||
"",
|
||||
"/** A policy that can be applied to a route or a set of routes. */",
|
||||
"export interface Policy {",
|
||||
" /** Optional policy name. */",
|
||||
" name?: string;",
|
||||
"",
|
||||
" /**",
|
||||
" * Applies the policy to the request.",
|
||||
" *",
|
||||
" * Policies _MUST_ call `next()` to pass the request to the next policy _OR_ call `response.end()` to terminate,",
|
||||
" * and _MUST NOT_ do both.",
|
||||
" *",
|
||||
" * If the policy passes a `request` object to `next()`, that request object will be used instead of the original",
|
||||
" * request object for the remainder of the policy chain. If the policy does _not_ pass a request object to `next()`,",
|
||||
" * the same object that was passed to this policy will be forwarded to the next policy automatically.",
|
||||
" *",
|
||||
" * @param request - The incoming HTTP request.",
|
||||
" * @param response - The outgoing HTTP response.",
|
||||
" * @param next - Calls the next policy in the chain.",
|
||||
" */",
|
||||
" (",
|
||||
" request: http.IncomingMessage,",
|
||||
" response: http.ServerResponse,",
|
||||
" next: (request?: http.IncomingMessage) => void",
|
||||
" ): void;",
|
||||
"}",
|
||||
"",
|
||||
"/**",
|
||||
" * Create a function from a chain of policies.",
|
||||
" *",
|
||||
" * This returns a single function that will apply the policy chain and eventually call the provided `next()` function.",
|
||||
" *",
|
||||
" * @param name - The name to give to the policy chain function.",
|
||||
" * @param policies - The policies to apply to the request.",
|
||||
" * @param out - The function to call after the policies have been applied.",
|
||||
" */",
|
||||
"export function createPolicyChain<",
|
||||
" Out extends (",
|
||||
" ctx: HttpContext,",
|
||||
" request: http.IncomingMessage,",
|
||||
" response: http.ServerResponse,",
|
||||
" ...rest: any[]",
|
||||
" ) => void,",
|
||||
">(name: string, policies: Policy[], out: Out): Out {",
|
||||
" let outParams: any[];",
|
||||
" if (policies.length === 0) {",
|
||||
" return out;",
|
||||
" }",
|
||||
"",
|
||||
" function applyPolicy(",
|
||||
" ctx: HttpContext,",
|
||||
" request: http.IncomingMessage,",
|
||||
" response: http.ServerResponse,",
|
||||
" index: number",
|
||||
" ) {",
|
||||
" if (index >= policies.length) {",
|
||||
" return out(ctx, request, response, ...outParams);",
|
||||
" }",
|
||||
"",
|
||||
" policies[index](request, response, function nextPolicy(nextRequest) {",
|
||||
" applyPolicy(ctx, nextRequest ?? request, response, index + 1);",
|
||||
" });",
|
||||
" }",
|
||||
"",
|
||||
" return {",
|
||||
" [name](",
|
||||
" ctx: HttpContext,",
|
||||
" request: http.IncomingMessage,",
|
||||
" response: http.ServerResponse,",
|
||||
" ...params: any[]",
|
||||
" ) {",
|
||||
" outParams = params;",
|
||||
" applyPolicy(ctx, request, response, 0);",
|
||||
" },",
|
||||
" }[name] as Out;",
|
||||
"}",
|
||||
"",
|
||||
"/**",
|
||||
" * The type of an error encountered during request validation.",
|
||||
" */",
|
||||
"export type ValidationError = string;",
|
||||
"",
|
||||
"/**",
|
||||
" * An object specifying the policies for a given route configuration.",
|
||||
" */",
|
||||
"export type RoutePolicies<RouteConfig extends { [k: string]: object }> = {",
|
||||
" [Interface in keyof RouteConfig]?: {",
|
||||
" before?: Policy[];",
|
||||
" after?: Policy[];",
|
||||
" methodPolicies?: {",
|
||||
" [Method in keyof RouteConfig[Interface]]?: Policy[];",
|
||||
" };",
|
||||
" };",
|
||||
"};",
|
||||
"",
|
||||
"/**",
|
||||
" * Create a policy chain for a given route.",
|
||||
" *",
|
||||
" * This function calls `createPolicyChain` internally and orders the policies based on the route configuration.",
|
||||
" *",
|
||||
" * Interface-level `before` policies run first, then method-level policies, then Interface-level `after` policies.",
|
||||
" *",
|
||||
" * @param name - The name to give to the policy chain function.",
|
||||
" * @param routePolicies - The policies to apply to the routes (part of the route configuration).",
|
||||
" * @param interfaceName - The name of the interface that the route belongs to.",
|
||||
" * @param methodName - The name of the method that the route corresponds to.",
|
||||
" * @param out - The function to call after the policies have been applied.",
|
||||
" */",
|
||||
"export function createPolicyChainForRoute<",
|
||||
" RouteConfig extends { [k: string]: object },",
|
||||
" InterfaceName extends keyof RouteConfig,",
|
||||
" Out extends (",
|
||||
" ctx: HttpContext,",
|
||||
" request: http.IncomingMessage,",
|
||||
" response: http.ServerResponse,",
|
||||
" ...rest: any[]",
|
||||
" ) => void,",
|
||||
">(",
|
||||
" name: string,",
|
||||
" routePolicies: RoutePolicies<RouteConfig>,",
|
||||
" interfaceName: InterfaceName,",
|
||||
" methodName: keyof RouteConfig[InterfaceName],",
|
||||
" out: Out",
|
||||
"): Out {",
|
||||
" return createPolicyChain(",
|
||||
" name,",
|
||||
" [",
|
||||
" ...(routePolicies[interfaceName]?.before ?? []),",
|
||||
" ...(routePolicies[interfaceName]?.methodPolicies?.[methodName] ?? []),",
|
||||
" ...(routePolicies[interfaceName]?.after ?? []),",
|
||||
" ],",
|
||||
" out",
|
||||
" );",
|
||||
"}",
|
||||
"",
|
||||
"/**",
|
||||
" * Options for configuring a router with additional functionality.",
|
||||
" */",
|
||||
"export interface RouterOptions<",
|
||||
" RouteConfig extends { [k: string]: object } = { [k: string]: object },",
|
||||
"> {",
|
||||
" /**",
|
||||
" * The base path of the router.",
|
||||
" *",
|
||||
" * This should include any leading slashes, but not a trailing slash, and should not include any component",
|
||||
" * of the URL authority (e.g. the scheme, host, or port).",
|
||||
" *",
|
||||
" * Defaults to \"\".",
|
||||
" */",
|
||||
" basePath?: string;",
|
||||
"",
|
||||
" /**",
|
||||
" * A list of policies to apply to all routes _before_ routing.",
|
||||
" *",
|
||||
" * Policies are applied in the order they are listed.",
|
||||
" *",
|
||||
" * By default, the policy list is empty.",
|
||||
" *",
|
||||
" * Policies _MUST_ call `next()` to pass the request to the next policy _OR_ call `response.end()` to terminate",
|
||||
" * the response and _MUST NOT_ do both.",
|
||||
" */",
|
||||
" policies?: Policy[];",
|
||||
"",
|
||||
" /**",
|
||||
" * A record of policies that apply to specific routes.",
|
||||
" *",
|
||||
" * The policies are provided as a nested record where the keys are the business-logic interface names, and the values",
|
||||
" * are records of the method names in the given interface and the policies that apply to them.",
|
||||
" *",
|
||||
" * By default, no additional policies are applied to the routes.",
|
||||
" *",
|
||||
" * Policies _MUST_ call `next()` to pass the request to the next policy _OR_ call `response.end()` to terminate",
|
||||
" * the response and _MUST NOT_ do both.",
|
||||
" */",
|
||||
" routePolicies?: RoutePolicies<RouteConfig>;",
|
||||
"",
|
||||
" /**",
|
||||
" * A handler for requests that do not match any known route and method.",
|
||||
" *",
|
||||
" * If this handler is not provided, a 404 Not Found response with a text body will be returned.",
|
||||
" *",
|
||||
" * You _MUST_ call `response.end()` to terminate the response.",
|
||||
" *",
|
||||
" * This handler is unreachable when using the Express middleware, as it will forward non-matching requests to the",
|
||||
" * next middleware layer in the stack.",
|
||||
" *",
|
||||
" * @param request - The incoming HTTP request.",
|
||||
" * @param response - The outgoing HTTP response.",
|
||||
" */",
|
||||
" onRequestNotFound?: (request: http.IncomingMessage, response: http.ServerResponse) => void;",
|
||||
"",
|
||||
" /**",
|
||||
" * A handler for requests that fail to validate inputs.",
|
||||
" *",
|
||||
" * If this handler is not provided, a 400 Bad Request response with a JSON body containing some basic information",
|
||||
" * about the error will be returned to the client.",
|
||||
" *",
|
||||
" * You _MUST_ call `response.end()` to terminate the response.",
|
||||
" *",
|
||||
" * @param request - The incoming HTTP request.",
|
||||
" * @param response - The outgoing HTTP response.",
|
||||
" * @param route - The route that was matched.",
|
||||
" * @param error - The validation error that was thrown.",
|
||||
" */",
|
||||
" onInvalidRequest?: (",
|
||||
" request: http.IncomingMessage,",
|
||||
" response: http.ServerResponse,",
|
||||
" route: string,",
|
||||
" error: ValidationError",
|
||||
" ) => void;",
|
||||
"",
|
||||
" /**",
|
||||
" * A handler for requests that throw an error during processing.",
|
||||
" *",
|
||||
" * If this handler is not provided, a 500 Internal Server Error response with a text body and no error details will be",
|
||||
" * returned to the client.",
|
||||
" *",
|
||||
" * You _MUST_ call `response.end()` to terminate the response.",
|
||||
" *",
|
||||
" * If this handler itself throws an Error, the router will respond with a 500 Internal Server Error",
|
||||
" *",
|
||||
" * @param error - The error that was thrown.",
|
||||
" * @param request - The incoming HTTP request.",
|
||||
" * @param response - The outgoing HTTP response.",
|
||||
" */",
|
||||
" onInternalError?(",
|
||||
" error: unknown,",
|
||||
" request: http.IncomingMessage,",
|
||||
" response: http.ServerResponse",
|
||||
" ): void;",
|
||||
"}",
|
||||
"",
|
||||
"/** Context information for operations carried over the HTTP protocol. */",
|
||||
"export interface HttpContext {",
|
||||
" /** The incoming request to the server. */",
|
||||
" request: http.IncomingMessage;",
|
||||
" /** The outgoing response object. */",
|
||||
" response: http.ServerResponse;",
|
||||
"}",
|
||||
"",
|
||||
];
|
||||
|
||||
export async function createModule(parent: Module): Promise<Module> {
|
||||
if (module) return module;
|
||||
|
||||
module = {
|
||||
name: "router",
|
||||
cursor: parent.cursor.enter("router"),
|
||||
imports: [],
|
||||
declarations: [],
|
||||
};
|
||||
|
||||
module.declarations.push(lines);
|
||||
|
||||
parent.declarations.push(module);
|
||||
|
||||
return module;
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"name": "@typespec/http-server-javascript",
|
||||
"version": "0.58.0-alpha.1",
|
||||
"author": "Microsoft Corporation",
|
||||
"description": "TypeSpec HTTP server code generator for JavaScript",
|
||||
"homepage": "https://github.com/microsoft/typespec",
|
||||
"readme": "https://github.com/microsoft/typespec/blob/main/packages/http-server-javascript/README.md",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/microsoft/typespec.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/microsoft/typespec/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"typespec",
|
||||
"http",
|
||||
"server",
|
||||
"javascript",
|
||||
"typescript"
|
||||
],
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
"exports": {
|
||||
".": "./dist/src/index.js",
|
||||
"./testing": "./dist/src/testing/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf ./dist ./temp",
|
||||
"build": "npm run build:helpers && npm run build:src",
|
||||
"build:src": "tsc -p ./tsconfig.json",
|
||||
"build:helpers": "tsx ./build-helpers.ts",
|
||||
"watch": "tsc -p . --watch",
|
||||
"test": "echo No tests specified",
|
||||
"test:ci": "echo No tests specified",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"regen-docs": "echo Doc generation disabled for this package."
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typespec/compiler": "workspace:~",
|
||||
"@typespec/http": "workspace:~"
|
||||
},
|
||||
"dependencies": {
|
||||
"prettier": "~3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "~18.11.19",
|
||||
"@typespec/compiler": "workspace:~",
|
||||
"@typespec/http": "workspace:~",
|
||||
"typescript": "~5.5.3",
|
||||
"tsx": "^4.16.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { DeclarationType, JsContext, Module } from "../ctx.js";
|
||||
import { emitEnum } from "./enum.js";
|
||||
import { emitInterface } from "./interface.js";
|
||||
import { emitModel } from "./model.js";
|
||||
import { emitScalar } from "./scalar.js";
|
||||
import { emitUnion } from "./union.js";
|
||||
|
||||
/**
|
||||
* Emit a declaration for a module based on its type.
|
||||
*
|
||||
* The altName is optional and is only used for unnamed models and unions.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param type - The type to emit.
|
||||
* @param module - The module that this declaration is written into.
|
||||
* @param altName - An alternative name to use for the declaration if it is not named.
|
||||
*/
|
||||
export function* emitDeclaration(
|
||||
ctx: JsContext,
|
||||
type: DeclarationType,
|
||||
module: Module,
|
||||
altName?: string
|
||||
): Iterable<string> {
|
||||
switch (type.kind) {
|
||||
case "Model": {
|
||||
yield* emitModel(ctx, type, module, altName);
|
||||
break;
|
||||
}
|
||||
case "Enum": {
|
||||
yield* emitEnum(ctx, type);
|
||||
break;
|
||||
}
|
||||
case "Union": {
|
||||
yield* emitUnion(ctx, type, module, altName);
|
||||
break;
|
||||
}
|
||||
case "Interface": {
|
||||
yield* emitInterface(ctx, type, module);
|
||||
break;
|
||||
}
|
||||
case "Scalar": {
|
||||
yield emitScalar(ctx, type);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`UNREACHABLE: Unhandled type kind: ${(type satisfies never as any).kind}`);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Type, getDoc } from "@typespec/compiler";
|
||||
import { JsContext } from "../ctx.js";
|
||||
import { indent } from "../util/iter.js";
|
||||
|
||||
/**
|
||||
* Emit the documentation for a type in JSDoc format.
|
||||
*
|
||||
* This assumes that the documentation may include Markdown formatting.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param type - The type to emit documentation for.
|
||||
*/
|
||||
export function* emitDocumentation(ctx: JsContext, type: Type): Iterable<string> {
|
||||
const doc = getDoc(ctx.program, type);
|
||||
|
||||
if (doc === undefined) return;
|
||||
|
||||
yield `/**`;
|
||||
|
||||
yield* indent(doc.trim().split(/\r?\n/g), " * ");
|
||||
|
||||
yield ` */`;
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Enum } from "@typespec/compiler";
|
||||
import { JsContext } from "../ctx.js";
|
||||
import { parseCase } from "../util/case.js";
|
||||
import { emitDocumentation } from "./documentation.js";
|
||||
|
||||
/**
|
||||
* Emit an enum declaration.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param enum_ - The enum to emit.
|
||||
*/
|
||||
export function* emitEnum(ctx: JsContext, enum_: Enum): Iterable<string> {
|
||||
yield* emitDocumentation(ctx, enum_);
|
||||
|
||||
const name = parseCase(enum_.name);
|
||||
|
||||
yield `export enum ${name.pascalCase} {`;
|
||||
|
||||
for (const member of enum_.members.values()) {
|
||||
yield ` ${member.name} = ${member.value},`;
|
||||
}
|
||||
|
||||
yield `}`;
|
||||
}
|
|
@ -0,0 +1,262 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Interface, Operation, Type, UnionVariant, isErrorModel } from "@typespec/compiler";
|
||||
import { JsContext, Module, PathCursor } from "../ctx.js";
|
||||
import { parseCase } from "../util/case.js";
|
||||
import { getAllProperties } from "../util/extends.js";
|
||||
import { bifilter, indent } from "../util/iter.js";
|
||||
import { emitDocumentation } from "./documentation.js";
|
||||
import { emitTypeReference, isValueLiteralType } from "./reference.js";
|
||||
import { emitUnionType } from "./union.js";
|
||||
|
||||
/**
|
||||
* Emit an interface declaration.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param iface - The interface to emit.
|
||||
* @param module - The module that this interface is written into.
|
||||
*/
|
||||
export function* emitInterface(ctx: JsContext, iface: Interface, module: Module): Iterable<string> {
|
||||
const name = parseCase(iface.name).pascalCase;
|
||||
|
||||
yield* emitDocumentation(ctx, iface);
|
||||
yield `export interface ${name}<Context = unknown> {`;
|
||||
yield* indent(emitOperationGroup(ctx, iface.operations.values(), module));
|
||||
yield "}";
|
||||
yield "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a list of operation signatures.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param operations - The operations to emit.
|
||||
* @param module - The module that the operations are written into.
|
||||
*/
|
||||
export function* emitOperationGroup(
|
||||
ctx: JsContext,
|
||||
operations: Iterable<Operation>,
|
||||
module: Module
|
||||
): Iterable<string> {
|
||||
for (const op of operations) {
|
||||
yield* emitOperation(ctx, op, module);
|
||||
yield "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a single operation signature.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param op - The operation to emit.
|
||||
* @param module - The module that the operation is written into.
|
||||
*/
|
||||
export function* emitOperation(ctx: JsContext, op: Operation, module: Module): Iterable<string> {
|
||||
const opNameCase = parseCase(op.name);
|
||||
|
||||
const opName = opNameCase.camelCase;
|
||||
|
||||
const hasOptions = getAllProperties(op.parameters).some((p) => p.optional);
|
||||
|
||||
const returnTypeReference = emitTypeReference(ctx, op.returnType, op, module, {
|
||||
altName: opNameCase.pascalCase + "Result",
|
||||
});
|
||||
|
||||
const returnType = `Promise<${returnTypeReference}>`;
|
||||
|
||||
const params: string[] = [];
|
||||
|
||||
for (const param of getAllProperties(op.parameters)) {
|
||||
// If the type is a value literal, then we consider it a _setting_ and not a parameter.
|
||||
// This allows us to exclude metadata parameters (such as contentType) from the generated interface.
|
||||
if (param.optional || isValueLiteralType(param.type)) continue;
|
||||
|
||||
const paramNameCase = parseCase(param.name);
|
||||
const paramName = paramNameCase.camelCase;
|
||||
|
||||
const outputTypeReference = emitTypeReference(ctx, param.type, param, module, {
|
||||
altName: opNameCase.pascalCase + paramNameCase.pascalCase,
|
||||
});
|
||||
|
||||
params.push(`${paramName}: ${outputTypeReference}`);
|
||||
}
|
||||
|
||||
const paramsDeclarationLine = params.join(", ");
|
||||
|
||||
yield* emitDocumentation(ctx, op);
|
||||
|
||||
if (hasOptions) {
|
||||
const optionsTypeName = opNameCase.pascalCase + "Options";
|
||||
|
||||
emitOptionsType(ctx, op, module, optionsTypeName);
|
||||
|
||||
const paramsFragment = params.length > 0 ? `${paramsDeclarationLine}, ` : "";
|
||||
|
||||
// prettier-ignore
|
||||
yield `${opName}(ctx: Context, ${paramsFragment}options?: ${optionsTypeName}): ${returnType};`;
|
||||
yield "";
|
||||
} else {
|
||||
// prettier-ignore
|
||||
yield `${opName}(ctx: Context, ${paramsDeclarationLine}): ${returnType};`;
|
||||
yield "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a declaration for an options type including the optional parameters of an operation.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param operation - The operation to emit the options type for.
|
||||
* @param module - The module that the options type is written into.
|
||||
* @param optionsTypeName - The name of the options type.
|
||||
*/
|
||||
export function emitOptionsType(
|
||||
ctx: JsContext,
|
||||
operation: Operation,
|
||||
module: Module,
|
||||
optionsTypeName: string
|
||||
) {
|
||||
module.imports.push({
|
||||
binder: [optionsTypeName],
|
||||
from: ctx.syntheticModule,
|
||||
});
|
||||
|
||||
const options = [...operation.parameters.properties.values()].filter((p) => p.optional);
|
||||
|
||||
ctx.syntheticModule.declarations.push([
|
||||
`export interface ${optionsTypeName} {`,
|
||||
...options.flatMap((p) => [
|
||||
` ${parseCase(p.name).camelCase}?: ${emitTypeReference(ctx, p.type, p, module, {
|
||||
altName: optionsTypeName + parseCase(p.name).pascalCase,
|
||||
})};`,
|
||||
]),
|
||||
"}",
|
||||
"",
|
||||
]);
|
||||
}
|
||||
|
||||
export interface SplitReturnTypeCommon {
|
||||
typeReference: string;
|
||||
target: Type | [PathCursor, string] | undefined;
|
||||
}
|
||||
|
||||
export interface OrdinarySplitReturnType extends SplitReturnTypeCommon {
|
||||
kind: "ordinary";
|
||||
}
|
||||
|
||||
export interface UnionSplitReturnType extends SplitReturnTypeCommon {
|
||||
kind: "union";
|
||||
variants: UnionVariant[];
|
||||
}
|
||||
|
||||
export type SplitReturnType = OrdinarySplitReturnType | UnionSplitReturnType;
|
||||
|
||||
const DEFAULT_NO_VARIANT_RETURN_TYPE = "never";
|
||||
const DEFAULT_NO_VARIANT_SPLIT: SplitReturnType = {
|
||||
kind: "ordinary",
|
||||
typeReference: DEFAULT_NO_VARIANT_RETURN_TYPE,
|
||||
target: undefined,
|
||||
};
|
||||
|
||||
export function isInfallible(split: SplitReturnType): boolean {
|
||||
return (
|
||||
(split.kind === "ordinary" && split.typeReference === "never") ||
|
||||
(split.kind === "union" && split.variants.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function splitReturnType(
|
||||
ctx: JsContext,
|
||||
type: Type,
|
||||
module: Module,
|
||||
altBaseName: string
|
||||
): [SplitReturnType, SplitReturnType] {
|
||||
const successAltName = altBaseName + "Response";
|
||||
const errorAltName = altBaseName + "ErrorResponse";
|
||||
|
||||
if (type.kind === "Union") {
|
||||
const [successVariants, errorVariants] = bifilter(
|
||||
type.variants.values(),
|
||||
(v) => !isErrorModel(ctx.program, v.type)
|
||||
);
|
||||
|
||||
const successTypeReference =
|
||||
successVariants.length === 0
|
||||
? DEFAULT_NO_VARIANT_RETURN_TYPE
|
||||
: successVariants.length === 1
|
||||
? emitTypeReference(ctx, successVariants[0].type, successVariants[0], module, {
|
||||
altName: successAltName,
|
||||
})
|
||||
: emitUnionType(ctx, successVariants, module);
|
||||
|
||||
const errorTypeReference =
|
||||
errorVariants.length === 0
|
||||
? DEFAULT_NO_VARIANT_RETURN_TYPE
|
||||
: errorVariants.length === 1
|
||||
? emitTypeReference(ctx, errorVariants[0].type, errorVariants[0], module, {
|
||||
altName: errorAltName,
|
||||
})
|
||||
: emitUnionType(ctx, errorVariants, module);
|
||||
|
||||
const successSplit: SplitReturnType =
|
||||
successVariants.length > 1
|
||||
? {
|
||||
kind: "union",
|
||||
variants: successVariants,
|
||||
typeReference: successTypeReference,
|
||||
target: undefined,
|
||||
}
|
||||
: {
|
||||
kind: "ordinary",
|
||||
typeReference: successTypeReference,
|
||||
target: successVariants[0]?.type,
|
||||
};
|
||||
|
||||
const errorSplit: SplitReturnType =
|
||||
errorVariants.length > 1
|
||||
? {
|
||||
kind: "union",
|
||||
variants: errorVariants,
|
||||
typeReference: errorTypeReference,
|
||||
// target: module.cursor.resolveRelativeItemPath(errorTypeReference),
|
||||
target: undefined,
|
||||
}
|
||||
: {
|
||||
kind: "ordinary",
|
||||
typeReference: errorTypeReference,
|
||||
target: errorVariants[0]?.type,
|
||||
};
|
||||
|
||||
return [successSplit, errorSplit];
|
||||
} else {
|
||||
// No splitting, just figure out if the type is an error type or not and make the other infallible.
|
||||
|
||||
if (isErrorModel(ctx.program, type)) {
|
||||
const typeReference = emitTypeReference(ctx, type, type, module, {
|
||||
altName: altBaseName + "ErrorResponse",
|
||||
});
|
||||
|
||||
return [
|
||||
DEFAULT_NO_VARIANT_SPLIT,
|
||||
{
|
||||
kind: "ordinary",
|
||||
typeReference,
|
||||
target: type,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
const typeReference = emitTypeReference(ctx, type, type, module, {
|
||||
altName: altBaseName + "SuccessResponse",
|
||||
});
|
||||
return [
|
||||
{
|
||||
kind: "ordinary",
|
||||
typeReference,
|
||||
target: type,
|
||||
},
|
||||
DEFAULT_NO_VARIANT_SPLIT,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import {
|
||||
Model,
|
||||
getFriendlyName,
|
||||
isTemplateDeclaration,
|
||||
isTemplateInstance,
|
||||
} from "@typespec/compiler";
|
||||
import { JsContext, Module } from "../ctx.js";
|
||||
import { parseCase } from "../util/case.js";
|
||||
import { indent } from "../util/iter.js";
|
||||
import { KEYWORDS } from "../util/keywords.js";
|
||||
import { getFullyQualifiedTypeName } from "../util/name.js";
|
||||
import { asArrayType, getArrayElementName, getRecordValueName } from "../util/pluralism.js";
|
||||
import { emitDocumentation } from "./documentation.js";
|
||||
import { emitTypeReference } from "./reference.js";
|
||||
|
||||
/**
|
||||
* Emit a model declaration.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param model - The model to emit.
|
||||
* @param module - The module that this model is written into.
|
||||
* @param altName - An alternative name to use for the model if it is not named.
|
||||
*/
|
||||
export function* emitModel(
|
||||
ctx: JsContext,
|
||||
model: Model,
|
||||
module: Module,
|
||||
altName?: string
|
||||
): Iterable<string> {
|
||||
const isTemplate = isTemplateInstance(model);
|
||||
const friendlyName = getFriendlyName(ctx.program, model);
|
||||
|
||||
if (isTemplateDeclaration(model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modelNameCase = parseCase(
|
||||
friendlyName
|
||||
? friendlyName
|
||||
: isTemplate
|
||||
? model.templateMapper!.args.map((a) => ("name" in a ? String(a.name) : "")).join("_") +
|
||||
model.name
|
||||
: model.name
|
||||
);
|
||||
|
||||
if (model.name === "" && !altName) {
|
||||
throw new Error("UNREACHABLE: Anonymous model with no altName");
|
||||
}
|
||||
|
||||
yield* emitDocumentation(ctx, model);
|
||||
|
||||
const ifaceName = model.name === "" ? altName! : modelNameCase.pascalCase;
|
||||
|
||||
const extendsClause = model.baseModel
|
||||
? `extends ${emitTypeReference(ctx, model.baseModel, model, module)} `
|
||||
: "";
|
||||
|
||||
yield `export interface ${ifaceName} ${extendsClause}{`;
|
||||
|
||||
for (const field of model.properties.values()) {
|
||||
const nameCase = parseCase(field.name);
|
||||
const basicName = nameCase.camelCase;
|
||||
|
||||
const typeReference = emitTypeReference(ctx, field.type, field, module, {
|
||||
altName: modelNameCase.pascalCase + nameCase.pascalCase,
|
||||
});
|
||||
|
||||
const name = KEYWORDS.has(basicName) ? `_${basicName}` : basicName;
|
||||
|
||||
yield* indent(emitDocumentation(ctx, field));
|
||||
|
||||
const questionMark = field.optional ? "?" : "";
|
||||
|
||||
yield ` ${name}${questionMark}: ${typeReference};`;
|
||||
yield "";
|
||||
}
|
||||
|
||||
yield "}";
|
||||
yield "";
|
||||
}
|
||||
|
||||
export function emitModelLiteral(ctx: JsContext, model: Model, module: Module): string {
|
||||
const properties = [...model.properties.values()].map((prop) => {
|
||||
const nameCase = parseCase(prop.name);
|
||||
const questionMark = prop.optional ? "?" : "";
|
||||
|
||||
const name = KEYWORDS.has(nameCase.camelCase) ? `_${nameCase.camelCase}` : nameCase.camelCase;
|
||||
|
||||
return `${name}${questionMark}: ${emitTypeReference(ctx, prop.type, prop, module)}`;
|
||||
});
|
||||
|
||||
return `{ ${properties.join("; ")} }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a model is an instance of a well-known model, such as TypeSpec.Record or TypeSpec.Array.
|
||||
*/
|
||||
export function isWellKnownModel(ctx: JsContext, type: Model): boolean {
|
||||
const fullName = getFullyQualifiedTypeName(type);
|
||||
return fullName === "TypeSpec.Record" || fullName === "TypeSpec.Array";
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a well-known model, such as TypeSpec.Record or TypeSpec.Array.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param type - The model to emit.
|
||||
* @param module - The module that this model is written into.
|
||||
* @param preferredAlternativeName - An alternative name to use for the model if it is not named.
|
||||
*/
|
||||
export function emitWellKnownModel(
|
||||
ctx: JsContext,
|
||||
type: Model,
|
||||
module: Module,
|
||||
preferredAlternativeName?: string
|
||||
): string {
|
||||
const arg = type.indexer!.value;
|
||||
switch (type.name) {
|
||||
case "Record": {
|
||||
return `{ [k: string]: ${emitTypeReference(ctx, arg, type, module, {
|
||||
altName: preferredAlternativeName && getRecordValueName(preferredAlternativeName),
|
||||
})} }`;
|
||||
}
|
||||
case "Array": {
|
||||
return asArrayType(
|
||||
emitTypeReference(ctx, arg, type, module, {
|
||||
altName: preferredAlternativeName && getArrayElementName(preferredAlternativeName),
|
||||
})
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error(`UNREACHABLE: ${type.name}`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,243 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Namespace, getNamespaceFullName } from "@typespec/compiler";
|
||||
import {
|
||||
DeclarationType,
|
||||
JsContext,
|
||||
Module,
|
||||
ModuleBodyDeclaration,
|
||||
createModule,
|
||||
isModule,
|
||||
} from "../ctx.js";
|
||||
import { parseCase } from "../util/case.js";
|
||||
import { UnimplementedError } from "../util/error.js";
|
||||
import { cat, indent, isIterable } from "../util/iter.js";
|
||||
import { OnceQueue } from "../util/once-queue.js";
|
||||
import { emitOperationGroup } from "./interface.js";
|
||||
|
||||
/**
|
||||
* Enqueue all declarations in the namespace to be included in the emit, recursively.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param namespace - The root namespace to begin traversing.
|
||||
*/
|
||||
export function visitAllTypes(ctx: JsContext, namespace: Namespace) {
|
||||
const { enums, interfaces, models, unions, namespaces, scalars, operations } = namespace;
|
||||
|
||||
for (const type of cat<DeclarationType>(
|
||||
enums.values(),
|
||||
interfaces.values(),
|
||||
models.values(),
|
||||
unions.values(),
|
||||
scalars.values()
|
||||
)) {
|
||||
ctx.typeQueue.add(type);
|
||||
}
|
||||
|
||||
for (const ns of namespaces.values()) {
|
||||
visitAllTypes(ctx, ns);
|
||||
}
|
||||
|
||||
if (operations.size > 0) {
|
||||
// If the operation has any floating operations in it, we will synthesize an interface for them in the parent module.
|
||||
// This requires some special handling by other parts of the emitter to ensure that the interface for a namespace's
|
||||
// own operations is properly imported.
|
||||
if (!namespace.namespace) {
|
||||
throw new UnimplementedError("no parent namespace in visitAllTypes");
|
||||
}
|
||||
|
||||
const parentModule = createOrGetModuleForNamespace(ctx, namespace.namespace);
|
||||
|
||||
parentModule.declarations.push([
|
||||
// prettier-ignore
|
||||
`/** An interface representing the operations defined in the '${getNamespaceFullName(namespace)}' namespace. */`,
|
||||
`export interface ${parseCase(namespace.name).pascalCase}<Context = unknown> {`,
|
||||
...indent(emitOperationGroup(ctx, operations.values(), parentModule)),
|
||||
"}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a module for a namespace, or get an existing module if one has already been created.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param namespace - The namespace to create a module for.
|
||||
* @returns the module for the namespace.
|
||||
*/
|
||||
export function createOrGetModuleForNamespace(
|
||||
ctx: JsContext,
|
||||
namespace: Namespace,
|
||||
root: Module = ctx.globalNamespaceModule
|
||||
): Module {
|
||||
if (ctx.namespaceModules.has(namespace)) {
|
||||
return ctx.namespaceModules.get(namespace)!;
|
||||
}
|
||||
|
||||
if (!namespace.namespace) {
|
||||
throw new Error("UNREACHABLE: no parent namespace in createOrGetModuleForNamespace");
|
||||
}
|
||||
|
||||
const parent =
|
||||
namespace.namespace === ctx.globalNamespace
|
||||
? root
|
||||
: createOrGetModuleForNamespace(ctx, namespace.namespace);
|
||||
const name = namespace.name === "TypeSpec" ? "typespec" : parseCase(namespace.name).kebabCase;
|
||||
|
||||
const module: Module = createModule(name, parent, namespace);
|
||||
|
||||
ctx.namespaceModules.set(namespace, module);
|
||||
|
||||
return module;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reference to the interface representing the namespace's floating operations.
|
||||
*
|
||||
* This does not check that such an interface actually exists, so it should only be called in situations where it is
|
||||
* known to exist (for example, if an operation comes from the namespace).
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param namespace - The namespace to get the interface reference for.
|
||||
* @param module - The module the the reference will be written to.
|
||||
*/
|
||||
export function emitNamespaceInterfaceReference(
|
||||
ctx: JsContext,
|
||||
namespace: Namespace,
|
||||
module: Module
|
||||
): string {
|
||||
if (!namespace.namespace) {
|
||||
throw new Error("UNREACHABLE: no parent namespace in emitNamespaceInterfaceReference");
|
||||
}
|
||||
|
||||
const namespaceName = parseCase(namespace.name).pascalCase;
|
||||
|
||||
module.imports.push({
|
||||
binder: [namespaceName],
|
||||
from: createOrGetModuleForNamespace(ctx, namespace.namespace),
|
||||
});
|
||||
|
||||
return namespaceName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a single declaration within a module. If the declaration is a module, it is enqueued for later processing.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param decl - The declaration to emit.
|
||||
* @param queue - The queue to add the declaration to if it is a module.
|
||||
*/
|
||||
function* emitModuleBodyDeclaration(
|
||||
ctx: JsContext,
|
||||
decl: ModuleBodyDeclaration,
|
||||
queue: OnceQueue<Module>
|
||||
): Iterable<string> {
|
||||
if (isIterable(decl)) {
|
||||
yield* decl;
|
||||
} else if (typeof decl === "string") {
|
||||
yield* decl.split(/\r?\n/);
|
||||
} else {
|
||||
if (decl.declarations.length > 0) {
|
||||
queue.add(decl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a file path from a given module to another module.
|
||||
*/
|
||||
function computeRelativeFilePath(from: Module, to: Module): string {
|
||||
const fromIsIndex = from.declarations.some((d) => isModule(d));
|
||||
const toIsIndex = to.declarations.some((d) => isModule(d));
|
||||
|
||||
const relativePath = (fromIsIndex ? from.cursor : from.cursor.parent!).relativePath(to.cursor);
|
||||
|
||||
if (relativePath.length === 0 && !toIsIndex)
|
||||
throw new Error("UNREACHABLE: relativePath returned no fragments");
|
||||
|
||||
if (relativePath.length === 0) return "./index.js";
|
||||
|
||||
const prefix = relativePath[0] === ".." ? "" : "./";
|
||||
|
||||
const suffix = toIsIndex ? "/index.js" : ".js";
|
||||
|
||||
return prefix + relativePath.join("/") + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates, consolidates, and writes the import statements for a module.
|
||||
*/
|
||||
function* writeImportsNormalized(ctx: JsContext, module: Module): Iterable<string> {
|
||||
const allTargets = new Set<string>();
|
||||
const importMap = new Map<string, Set<string>>();
|
||||
const starAsMap = new Map<string, string>();
|
||||
const extraStarAs: [string, string][] = [];
|
||||
|
||||
for (const _import of module.imports) {
|
||||
// check for same module and continue
|
||||
if (_import.from === module) continue;
|
||||
|
||||
const target =
|
||||
typeof _import.from === "string"
|
||||
? _import.from
|
||||
: computeRelativeFilePath(module, _import.from);
|
||||
|
||||
allTargets.add(target);
|
||||
|
||||
if (typeof _import.binder === "string") {
|
||||
if (starAsMap.has(target)) {
|
||||
extraStarAs.push([_import.binder, target]);
|
||||
} else {
|
||||
starAsMap.set(target, _import.binder);
|
||||
}
|
||||
} else {
|
||||
const binders = importMap.get(target) ?? new Set<string>();
|
||||
for (const binder of _import.binder) {
|
||||
binders.add(binder);
|
||||
}
|
||||
importMap.set(target, binders);
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of allTargets) {
|
||||
const binders = importMap.get(target);
|
||||
const starAs = starAsMap.get(target);
|
||||
|
||||
if (binders && starAs) {
|
||||
yield `import ${starAs}, { ${[...binders].join(", ")} } from "${target}";`;
|
||||
} else if (binders) {
|
||||
yield `import { ${[...binders].join(", ")} } from "${target}";`;
|
||||
} else if (starAs) {
|
||||
yield `import ${starAs} from "${target}";`;
|
||||
}
|
||||
|
||||
yield "";
|
||||
}
|
||||
|
||||
for (const [binder, target] of extraStarAs) {
|
||||
yield `import ${binder} from "${target}";`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the body of a module file.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param module - The module to emit.
|
||||
* @param queue - The queue to add any submodules to for later processing.
|
||||
*/
|
||||
export function* emitModuleBody(
|
||||
ctx: JsContext,
|
||||
module: Module,
|
||||
queue: OnceQueue<Module>
|
||||
): Iterable<string> {
|
||||
yield* writeImportsNormalized(ctx, module);
|
||||
|
||||
if (module.imports.length > 0) yield "";
|
||||
|
||||
for (const decl of module.declarations) {
|
||||
yield* emitModuleBodyDeclaration(ctx, decl, queue);
|
||||
yield "";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,319 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import {
|
||||
DiagnosticTarget,
|
||||
IntrinsicType,
|
||||
LiteralType,
|
||||
Namespace,
|
||||
NoTarget,
|
||||
Type,
|
||||
compilerAssert,
|
||||
getEffectiveModelType,
|
||||
getFriendlyName,
|
||||
isArrayModelType,
|
||||
} from "@typespec/compiler";
|
||||
import { JsContext, Module, isImportableType } from "../ctx.js";
|
||||
import { reportDiagnostic } from "../lib.js";
|
||||
import { parseCase } from "../util/case.js";
|
||||
import { asArrayType, getArrayElementName } from "../util/pluralism.js";
|
||||
import { emitModelLiteral, emitWellKnownModel, isWellKnownModel } from "./model.js";
|
||||
import { createOrGetModuleForNamespace } from "./namespace.js";
|
||||
import { getJsScalar } from "./scalar.js";
|
||||
import { emitUnionType } from "./union.js";
|
||||
|
||||
export type NamespacedType = Extract<Type, { namespace?: Namespace }>;
|
||||
|
||||
/**
|
||||
* Options for emitting a type reference.
|
||||
*/
|
||||
export interface EmitTypeReferenceOptions {
|
||||
/**
|
||||
* An optional alternative name to use for the type if it is not named.
|
||||
*/
|
||||
altName?: string;
|
||||
|
||||
/**
|
||||
* Require a declaration for types that may be represented anonymously.
|
||||
*/
|
||||
requireDeclaration?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a reference to a host type.
|
||||
*
|
||||
* This function will automatically ensure that the referenced type is included in the emit graph, and will import the
|
||||
* type into the current module if necessary.
|
||||
*
|
||||
* Optionally, a `preferredAlternativeName` may be supplied. This alternative name will be used if a declaration is
|
||||
* required, but the type is anonymous. The alternative name can only be set once. If two callers provide different
|
||||
* alternative names for the same anonymous type, the first one is used in all cases. If a declaration _is_ required,
|
||||
* and no alternative name is supplied (or has been supplied in a prior call to `emitTypeReference`), this function will
|
||||
* throw an error. Callers must be sure to provide an alternative name if the type _may_ have an unknown name. However,
|
||||
* callers may know that they have previously emitted a reference to the type and provided an alternative name in that
|
||||
* call, in which case the alternative name may be safely omitted.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param type - The type to emit a reference to.
|
||||
* @param position - The syntactic position of the reference, for diagnostics.
|
||||
* @param module - The module that the reference is being emitted into.
|
||||
* @param preferredAlternativeName - An optional alternative name to use for the type if it is not named.
|
||||
* @returns a string containing a reference to the TypeScript type that represents the given TypeSpec type.
|
||||
*/
|
||||
export function emitTypeReference(
|
||||
ctx: JsContext,
|
||||
type: Type,
|
||||
position: DiagnosticTarget | typeof NoTarget,
|
||||
module: Module,
|
||||
options: EmitTypeReferenceOptions = {}
|
||||
): string {
|
||||
switch (type.kind) {
|
||||
case "Scalar":
|
||||
// Get the scalar and return it directly, as it is a primitive.
|
||||
return getJsScalar(ctx.program, type, position);
|
||||
case "Model": {
|
||||
// First handle arrays.
|
||||
if (isArrayModelType(ctx.program, type)) {
|
||||
const argumentType = type.indexer.value;
|
||||
|
||||
const argTypeReference = emitTypeReference(ctx, argumentType, position, module, {
|
||||
altName: options.altName && getArrayElementName(options.altName),
|
||||
});
|
||||
|
||||
if (isImportableType(ctx, argumentType) && argumentType.namespace) {
|
||||
module.imports.push({
|
||||
binder: [argTypeReference],
|
||||
from: createOrGetModuleForNamespace(ctx, argumentType.namespace),
|
||||
});
|
||||
}
|
||||
|
||||
return asArrayType(argTypeReference);
|
||||
}
|
||||
|
||||
// Now other well-known models.
|
||||
if (isWellKnownModel(ctx, type)) {
|
||||
return emitWellKnownModel(ctx, type, module, options.altName);
|
||||
}
|
||||
|
||||
// Try to reduce the model to an effective model if possible.
|
||||
const effectiveModel = getEffectiveModelType(ctx.program, type);
|
||||
|
||||
if (effectiveModel.name === "") {
|
||||
// We might have seen the model before and synthesized a declaration for it already.
|
||||
if (ctx.syntheticNames.has(effectiveModel)) {
|
||||
const name = ctx.syntheticNames.get(effectiveModel)!;
|
||||
module.imports.push({
|
||||
binder: [name],
|
||||
from: ctx.syntheticModule,
|
||||
});
|
||||
return name;
|
||||
}
|
||||
|
||||
// Require preferredAlternativeName at this point, as we have an anonymous model that we have not visited.
|
||||
if (!options.altName) {
|
||||
return emitModelLiteral(ctx, effectiveModel, module);
|
||||
}
|
||||
|
||||
// Anonymous model, synthesize a new model with the preferredName
|
||||
ctx.synthetics.push({
|
||||
kind: "anonymous",
|
||||
name: options.altName,
|
||||
underlying: effectiveModel,
|
||||
});
|
||||
|
||||
module.imports.push({
|
||||
binder: [options.altName],
|
||||
from: ctx.syntheticModule,
|
||||
});
|
||||
|
||||
ctx.syntheticNames.set(effectiveModel, options.altName);
|
||||
|
||||
return options.altName;
|
||||
} else {
|
||||
// The effective model is good for a declaration, so enqueue it.
|
||||
ctx.typeQueue.add(effectiveModel);
|
||||
}
|
||||
|
||||
const friendlyName = getFriendlyName(ctx.program, effectiveModel);
|
||||
|
||||
// The model may be a template instance, so we generate a name for it.
|
||||
const templatedName = parseCase(
|
||||
friendlyName
|
||||
? friendlyName
|
||||
: effectiveModel.templateMapper
|
||||
? effectiveModel
|
||||
.templateMapper!.args.map((a) => ("name" in a ? String(a.name) : ""))
|
||||
.join("_") + effectiveModel.name
|
||||
: effectiveModel.name
|
||||
);
|
||||
|
||||
if (!effectiveModel.namespace) {
|
||||
throw new Error("UNREACHABLE: no parent namespace of named model in emitTypeReference");
|
||||
}
|
||||
|
||||
const parentModule = createOrGetModuleForNamespace(ctx, effectiveModel.namespace);
|
||||
|
||||
module.imports.push({
|
||||
binder: [templatedName.pascalCase],
|
||||
from: parentModule,
|
||||
});
|
||||
|
||||
return templatedName.pascalCase;
|
||||
}
|
||||
case "Union": {
|
||||
if (type.variants.size === 0) return "never";
|
||||
else if (type.variants.size === 1)
|
||||
return emitTypeReference(ctx, [...type.variants.values()][0], position, module, options);
|
||||
|
||||
if (options.requireDeclaration) {
|
||||
if (type.name) {
|
||||
const nameCase = parseCase(type.name);
|
||||
|
||||
ctx.typeQueue.add(type);
|
||||
|
||||
module.imports.push({
|
||||
binder: [nameCase.pascalCase],
|
||||
from: createOrGetModuleForNamespace(ctx, type.namespace!),
|
||||
});
|
||||
|
||||
return type.name;
|
||||
} else {
|
||||
const existingSyntheticName = ctx.syntheticNames.get(type);
|
||||
|
||||
if (existingSyntheticName) {
|
||||
module.imports.push({
|
||||
binder: [existingSyntheticName],
|
||||
from: ctx.syntheticModule,
|
||||
});
|
||||
|
||||
return existingSyntheticName;
|
||||
} else {
|
||||
const altName = options.altName;
|
||||
|
||||
if (!altName) {
|
||||
throw new Error("UNREACHABLE: anonymous union without preferredAlternativeName");
|
||||
}
|
||||
|
||||
ctx.synthetics.push({
|
||||
kind: "anonymous",
|
||||
name: altName,
|
||||
underlying: type,
|
||||
});
|
||||
|
||||
module.imports.push({
|
||||
binder: [altName],
|
||||
from: ctx.syntheticModule,
|
||||
});
|
||||
|
||||
ctx.syntheticNames.set(type, altName);
|
||||
|
||||
return altName;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return emitUnionType(ctx, [...type.variants.values()], module);
|
||||
}
|
||||
}
|
||||
case "Enum": {
|
||||
ctx.typeQueue.add(type);
|
||||
|
||||
const name = parseCase(type.name).pascalCase;
|
||||
|
||||
module.imports.push({
|
||||
binder: [name],
|
||||
from: createOrGetModuleForNamespace(ctx, type.namespace!),
|
||||
});
|
||||
|
||||
return name;
|
||||
}
|
||||
case "String":
|
||||
return escapeUnsafeChars(JSON.stringify(type.value));
|
||||
case "Number":
|
||||
case "Boolean":
|
||||
return String(type.value);
|
||||
case "Intrinsic":
|
||||
switch (type.name) {
|
||||
case "never":
|
||||
return "never";
|
||||
case "null":
|
||||
return "null";
|
||||
case "void":
|
||||
// It's a bit strange to have a void property, but it's possible, and TypeScript allows it. Void is simply
|
||||
// only assignable from undefined or void itself.
|
||||
return "void";
|
||||
case "ErrorType":
|
||||
compilerAssert(
|
||||
false,
|
||||
"ErrorType should not be encountered in emitTypeReference",
|
||||
position === NoTarget ? type : position
|
||||
);
|
||||
return "unknown";
|
||||
case "unknown":
|
||||
return "unknown";
|
||||
default:
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "unrecognized-intrinsic",
|
||||
format: { name: (type satisfies never as IntrinsicType).name },
|
||||
target: position,
|
||||
});
|
||||
return "unknown";
|
||||
}
|
||||
case "Interface": {
|
||||
if (type.namespace === undefined) {
|
||||
throw new Error("UNREACHABLE: unparented interface");
|
||||
}
|
||||
|
||||
const typeName = parseCase(type.name).pascalCase;
|
||||
|
||||
ctx.typeQueue.add(type);
|
||||
|
||||
const parentModule = createOrGetModuleForNamespace(ctx, type.namespace);
|
||||
|
||||
module.imports.push({
|
||||
binder: [typeName],
|
||||
from: parentModule,
|
||||
});
|
||||
|
||||
return typeName;
|
||||
}
|
||||
case "ModelProperty": {
|
||||
// Forward to underlying type.
|
||||
return emitTypeReference(ctx, type.type, position, module, options);
|
||||
}
|
||||
default:
|
||||
throw new Error(`UNREACHABLE: ${type.kind}`);
|
||||
}
|
||||
}
|
||||
const UNSAFE_CHAR_MAP: { [k: string]: string } = {
|
||||
"<": "\\u003C",
|
||||
">": "\\u003E",
|
||||
"/": "\\u002F",
|
||||
"\\": "\\\\",
|
||||
"\b": "\\b",
|
||||
"\f": "\\f",
|
||||
"\n": "\\n",
|
||||
"\r": "\\r",
|
||||
"\t": "\\t",
|
||||
"\0": "\\0",
|
||||
"\u2028": "\\u2028",
|
||||
"\u2029": "\\u2029",
|
||||
};
|
||||
|
||||
export function escapeUnsafeChars(s: string) {
|
||||
return s.replace(/[<>/\\\b\f\n\r\t\0\u2028\u2029]/g, (x) => UNSAFE_CHAR_MAP[x]);
|
||||
}
|
||||
|
||||
export type JsTypeSpecLiteralType = LiteralType | (IntrinsicType & { name: "null" });
|
||||
|
||||
export function isValueLiteralType(t: Type): t is JsTypeSpecLiteralType {
|
||||
switch (t.kind) {
|
||||
case "String":
|
||||
case "Number":
|
||||
case "Boolean":
|
||||
return true;
|
||||
case "Intrinsic":
|
||||
return t.name === "null";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { DiagnosticTarget, NoTarget, Program, Scalar, formatDiagnostic } from "@typespec/compiler";
|
||||
import { JsContext } from "../ctx.js";
|
||||
import { reportDiagnostic } from "../lib.js";
|
||||
import { parseCase } from "../util/case.js";
|
||||
import { UnimplementedError } from "../util/error.js";
|
||||
import { getFullyQualifiedTypeName } from "../util/name.js";
|
||||
|
||||
/**
|
||||
* Emits a declaration for a scalar type.
|
||||
*
|
||||
* This is rare in TypeScript, as the scalar will ordinarily be used inline, but may be desirable in some cases.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param scalar - The scalar to emit.
|
||||
* @returns a string that declares an alias to the scalar type in TypeScript.
|
||||
*/
|
||||
export function emitScalar(ctx: JsContext, scalar: Scalar): string {
|
||||
const jsScalar = getJsScalar(ctx.program, scalar, scalar.node.id);
|
||||
|
||||
const name = parseCase(scalar.name).pascalCase;
|
||||
|
||||
return `type ${name} = ${jsScalar};`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the string parsing template for a given scalar.
|
||||
*
|
||||
* It is common that a scalar type is encoded as a string. For example, in HTTP path parameters or query parameters
|
||||
* where the value may be an integer, but the APIs expose it as a string. In such cases the parse template may be
|
||||
* used to coerce the string value to the correct scalar type.
|
||||
*
|
||||
* The result of this function contains the string "{}" exactly once, which should be replaced with the text of an
|
||||
* expression evaluating to the string representation of the scalar.
|
||||
*
|
||||
* For example, scalars that are represented by JS `number` are parsed with the template `Number({})`, which will
|
||||
* convert the string to a number.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param scalar - The scalar to parse from a string
|
||||
* @returns a template expression string that can be used to parse a string into the scalar type.
|
||||
*/
|
||||
export function parseTemplateForScalar(ctx: JsContext, scalar: Scalar): string {
|
||||
const jsScalar = getJsScalar(ctx.program, scalar, scalar);
|
||||
|
||||
switch (jsScalar) {
|
||||
case "string":
|
||||
return "{}";
|
||||
case "number":
|
||||
return "Number({})";
|
||||
case "bigint":
|
||||
return "BigInt({})";
|
||||
default:
|
||||
throw new UnimplementedError(`parse template for scalar '${jsScalar}'`);
|
||||
}
|
||||
}
|
||||
|
||||
const __JS_SCALARS_MAP = new Map<Program, Map<Scalar, string>>();
|
||||
|
||||
function getScalarsMap(program: Program): Map<Scalar, string> {
|
||||
let scalars = __JS_SCALARS_MAP.get(program);
|
||||
|
||||
if (scalars === undefined) {
|
||||
scalars = createScalarsMap(program);
|
||||
__JS_SCALARS_MAP.set(program, scalars);
|
||||
}
|
||||
|
||||
return scalars;
|
||||
}
|
||||
|
||||
function createScalarsMap(program: Program): Map<Scalar, string> {
|
||||
const entries = [
|
||||
[program.resolveTypeReference("TypeSpec.bytes"), "Uint8Array"],
|
||||
[program.resolveTypeReference("TypeSpec.boolean"), "boolean"],
|
||||
[program.resolveTypeReference("TypeSpec.string"), "string"],
|
||||
[program.resolveTypeReference("TypeSpec.float32"), "number"],
|
||||
[program.resolveTypeReference("TypeSpec.float64"), "number"],
|
||||
|
||||
[program.resolveTypeReference("TypeSpec.uint32"), "number"],
|
||||
[program.resolveTypeReference("TypeSpec.uint16"), "number"],
|
||||
[program.resolveTypeReference("TypeSpec.uint8"), "number"],
|
||||
[program.resolveTypeReference("TypeSpec.int32"), "number"],
|
||||
[program.resolveTypeReference("TypeSpec.int16"), "number"],
|
||||
[program.resolveTypeReference("TypeSpec.int8"), "number"],
|
||||
|
||||
[program.resolveTypeReference("TypeSpec.safeint"), "number"],
|
||||
[program.resolveTypeReference("TypeSpec.integer"), "bigint"],
|
||||
[program.resolveTypeReference("TypeSpec.plainDate"), "Date"],
|
||||
[program.resolveTypeReference("TypeSpec.plainTime"), "Date"],
|
||||
[program.resolveTypeReference("TypeSpec.utcDateTime"), "Date"],
|
||||
] as const;
|
||||
|
||||
for (const [[type, diagnostics]] of entries) {
|
||||
if (!type) {
|
||||
const diagnosticString = diagnostics.map(formatDiagnostic).join("\n");
|
||||
throw new Error(`failed to construct TypeSpec -> JavaScript scalar map: ${diagnosticString}`);
|
||||
} else if (type.kind !== "Scalar") {
|
||||
throw new Error(
|
||||
`type ${(type as any).name ?? "<anonymous>"} is a '${type.kind}', expected 'scalar'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Map<Scalar, string>(entries.map(([[type], scalar]) => [type! as Scalar, scalar]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a TypeScript type that can represent a given TypeSpec scalar.
|
||||
*
|
||||
* Scalar recognition is recursive. If a scalar is not recognized, we will treat it as its parent scalar and try again.
|
||||
*
|
||||
* If no scalar in the chain is recognized, it will be treated as `unknown` and a warning will be issued.
|
||||
*
|
||||
* @param program - The program that contains the scalar
|
||||
* @param scalar - The scalar to get the TypeScript type for
|
||||
* @param diagnosticTarget - Where to report a diagnostic if the scalar is not recognized.
|
||||
* @returns a string containing a TypeScript type that can represent the scalar
|
||||
*/
|
||||
export function getJsScalar(
|
||||
program: Program,
|
||||
scalar: Scalar,
|
||||
diagnosticTarget: DiagnosticTarget | typeof NoTarget
|
||||
): string {
|
||||
const scalars = getScalarsMap(program);
|
||||
|
||||
let _scalar: Scalar | undefined = scalar;
|
||||
|
||||
while (_scalar !== undefined) {
|
||||
const jsScalar = scalars.get(_scalar);
|
||||
|
||||
if (jsScalar !== undefined) {
|
||||
return jsScalar;
|
||||
}
|
||||
|
||||
_scalar = _scalar.baseScalar;
|
||||
}
|
||||
|
||||
reportDiagnostic(program, {
|
||||
code: "unrecognized-scalar",
|
||||
target: diagnosticTarget,
|
||||
format: {
|
||||
scalar: getFullyQualifiedTypeName(scalar),
|
||||
},
|
||||
});
|
||||
|
||||
return "unknown";
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Model, NoTarget, Scalar, Type, Union } from "@typespec/compiler";
|
||||
import { JsContext, Module, completePendingDeclarations } from "../../ctx.js";
|
||||
import { UnimplementedError } from "../../util/error.js";
|
||||
import { indent } from "../../util/iter.js";
|
||||
import { createOrGetModuleForNamespace } from "../namespace.js";
|
||||
import { emitTypeReference } from "../reference.js";
|
||||
import { emitJsonSerialization, requiresJsonSerialization } from "./json.js";
|
||||
|
||||
export type SerializableType = Model | Scalar | Union;
|
||||
|
||||
export function isSerializableType(t: Type): t is SerializableType {
|
||||
return t.kind === "Model" || t.kind === "Scalar" || t.kind === "Union";
|
||||
}
|
||||
|
||||
export type SerializationContentType = "application/json";
|
||||
|
||||
const _SERIALIZATIONS_MAP = new WeakMap<SerializableType, Set<SerializationContentType>>();
|
||||
|
||||
export function requireSerialization(
|
||||
ctx: JsContext,
|
||||
type: Type,
|
||||
contentType: SerializationContentType
|
||||
): void {
|
||||
if (!isSerializableType(type)) {
|
||||
throw new UnimplementedError(`no implementation of JSON serialization for type '${type.kind}'`);
|
||||
}
|
||||
|
||||
let serializationsForType = _SERIALIZATIONS_MAP.get(type);
|
||||
|
||||
if (!serializationsForType) {
|
||||
serializationsForType = new Set();
|
||||
_SERIALIZATIONS_MAP.set(type, serializationsForType);
|
||||
}
|
||||
|
||||
serializationsForType.add(contentType);
|
||||
|
||||
ctx.serializations.add(type);
|
||||
}
|
||||
|
||||
export interface SerializationContext extends JsContext {}
|
||||
|
||||
export function emitSerialization(ctx: JsContext): void {
|
||||
completePendingDeclarations(ctx);
|
||||
|
||||
const serializationContext: SerializationContext = {
|
||||
...ctx,
|
||||
};
|
||||
|
||||
while (!ctx.serializations.isEmpty()) {
|
||||
const type = ctx.serializations.take()!;
|
||||
|
||||
const serializations = _SERIALIZATIONS_MAP.get(type)!;
|
||||
|
||||
const requiredSerializations = new Set<SerializationContentType>(
|
||||
[...serializations].filter((serialization) =>
|
||||
isSerializationRequired(ctx, type, serialization)
|
||||
)
|
||||
);
|
||||
|
||||
if (requiredSerializations.size > 0) {
|
||||
emitSerializationsForType(serializationContext, type, serializations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isSerializationRequired(
|
||||
ctx: JsContext,
|
||||
type: Type,
|
||||
serialization: SerializationContentType
|
||||
): boolean {
|
||||
switch (serialization) {
|
||||
case "application/json": {
|
||||
return requiresJsonSerialization(ctx, type);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unreachable: serialization content type ${serialization satisfies never}`);
|
||||
}
|
||||
}
|
||||
|
||||
function emitSerializationsForType(
|
||||
ctx: SerializationContext,
|
||||
type: SerializableType,
|
||||
serializations: Set<SerializationContentType>
|
||||
): void {
|
||||
const isSynthetic = ctx.syntheticNames.has(type) || !type.namespace;
|
||||
|
||||
const module = isSynthetic
|
||||
? ctx.syntheticModule
|
||||
: createOrGetModuleForNamespace(ctx, type.namespace!);
|
||||
|
||||
const typeName = emitTypeReference(ctx, type, NoTarget, module);
|
||||
|
||||
const serializationCode = [`export const ${typeName} = {`];
|
||||
|
||||
for (const serialization of serializations) {
|
||||
serializationCode.push(
|
||||
...indent(emitSerializationForType(ctx, type, serialization, module, typeName))
|
||||
);
|
||||
}
|
||||
|
||||
serializationCode.push("} as const;");
|
||||
|
||||
module.declarations.push(serializationCode);
|
||||
}
|
||||
|
||||
function* emitSerializationForType(
|
||||
ctx: SerializationContext,
|
||||
type: SerializableType,
|
||||
contentType: SerializationContentType,
|
||||
module: Module,
|
||||
typeName: string
|
||||
): Iterable<string> {
|
||||
switch (contentType) {
|
||||
case "application/json": {
|
||||
yield* emitJsonSerialization(ctx, type, module, typeName);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unreachable: serialization content type ${contentType satisfies never}`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,420 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import {
|
||||
BooleanLiteral,
|
||||
IntrinsicType,
|
||||
ModelProperty,
|
||||
NoTarget,
|
||||
NumericLiteral,
|
||||
StringLiteral,
|
||||
Type,
|
||||
compilerAssert,
|
||||
getEncode,
|
||||
getProjectedName,
|
||||
isArrayModelType,
|
||||
isRecordModelType,
|
||||
resolveEncodedName,
|
||||
} from "@typespec/compiler";
|
||||
import { getHeaderFieldOptions, getPathParamOptions, getQueryParamOptions } from "@typespec/http";
|
||||
import { JsContext, Module } from "../../ctx.js";
|
||||
import { parseCase } from "../../util/case.js";
|
||||
import { differentiateUnion, writeCodeTree } from "../../util/differentiate.js";
|
||||
import { UnimplementedError } from "../../util/error.js";
|
||||
import { indent } from "../../util/iter.js";
|
||||
import { emitTypeReference, escapeUnsafeChars } from "../reference.js";
|
||||
import { SerializableType, SerializationContext, requireSerialization } from "./index.js";
|
||||
|
||||
/**
|
||||
* Memoization cache for requiresJsonSerialization.
|
||||
*/
|
||||
const _REQUIRES_JSON_SERIALIZATION = new WeakMap<SerializableType | ModelProperty, boolean>();
|
||||
|
||||
export function requiresJsonSerialization(ctx: JsContext, type: Type): boolean {
|
||||
if (!isSerializable(type)) return false;
|
||||
|
||||
if (_REQUIRES_JSON_SERIALIZATION.has(type)) {
|
||||
return _REQUIRES_JSON_SERIALIZATION.get(type)!;
|
||||
}
|
||||
|
||||
// Assume the type is serializable until proven otherwise, in case this model is encountered recursively.
|
||||
// This isn't an exactly correct algorithm, but in the recursive case it will at least produce something that
|
||||
// is correct.
|
||||
_REQUIRES_JSON_SERIALIZATION.set(type, true);
|
||||
|
||||
let requiresSerialization: boolean;
|
||||
|
||||
switch (type.kind) {
|
||||
case "Model": {
|
||||
requiresSerialization = [...type.properties.values()].some((property) =>
|
||||
propertyRequiresJsonSerialization(ctx, property)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "Scalar": {
|
||||
requiresSerialization = getEncode(ctx.program, type) !== undefined;
|
||||
break;
|
||||
}
|
||||
case "Union": {
|
||||
requiresSerialization = [...type.variants.values()].some((variant) =>
|
||||
requiresJsonSerialization(ctx, variant)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "ModelProperty":
|
||||
requiresSerialization = requiresJsonSerialization(ctx, type.type);
|
||||
break;
|
||||
}
|
||||
|
||||
_REQUIRES_JSON_SERIALIZATION.set(type, requiresSerialization);
|
||||
|
||||
return requiresSerialization;
|
||||
}
|
||||
|
||||
function propertyRequiresJsonSerialization(ctx: JsContext, property: ModelProperty): boolean {
|
||||
return !!(
|
||||
isHttpMetadata(ctx, property) ||
|
||||
getEncode(ctx.program, property) ||
|
||||
resolveEncodedName(ctx.program, property, "application/json") !== property.name ||
|
||||
getProjectedName(ctx.program, property, "json") ||
|
||||
(isSerializable(property.type) && requiresJsonSerialization(ctx, property.type))
|
||||
);
|
||||
}
|
||||
|
||||
function isHttpMetadata(ctx: JsContext, property: ModelProperty): boolean {
|
||||
return (
|
||||
getQueryParamOptions(ctx.program, property) !== undefined ||
|
||||
getHeaderFieldOptions(ctx.program, property) !== undefined ||
|
||||
getPathParamOptions(ctx.program, property) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function isSerializable(type: Type): type is SerializableType | ModelProperty {
|
||||
return (
|
||||
type.kind === "Model" ||
|
||||
type.kind === "Scalar" ||
|
||||
type.kind === "Union" ||
|
||||
type.kind === "ModelProperty"
|
||||
);
|
||||
}
|
||||
|
||||
export function* emitJsonSerialization(
|
||||
ctx: SerializationContext,
|
||||
type: SerializableType,
|
||||
module: Module,
|
||||
typeName: string
|
||||
): Iterable<string> {
|
||||
yield `toJsonObject(input: ${typeName}): object {`;
|
||||
yield* indent(emitToJson(ctx, type, module));
|
||||
yield `},`;
|
||||
|
||||
yield `fromJsonObject(input: object): ${typeName} {`;
|
||||
yield* indent(emitFromJson(ctx, type, module));
|
||||
yield `},`;
|
||||
}
|
||||
|
||||
function* emitToJson(
|
||||
ctx: SerializationContext,
|
||||
type: SerializableType,
|
||||
module: Module
|
||||
): Iterable<string> {
|
||||
switch (type.kind) {
|
||||
case "Model": {
|
||||
yield `return {`;
|
||||
|
||||
for (const property of type.properties.values()) {
|
||||
const encodedName =
|
||||
getProjectedName(ctx.program, property, "json") ??
|
||||
resolveEncodedName(ctx.program, property, "application/json") ??
|
||||
property.name;
|
||||
|
||||
const expr = transposeExpressionToJson(
|
||||
ctx,
|
||||
property.type,
|
||||
`input.${property.name}`,
|
||||
module
|
||||
);
|
||||
|
||||
yield ` ${encodedName}: ${expr},`;
|
||||
}
|
||||
|
||||
yield `};`;
|
||||
|
||||
return;
|
||||
}
|
||||
case "Scalar": {
|
||||
yield `throw new Error("Unimplemented: scalar JSON serialization");`;
|
||||
return;
|
||||
}
|
||||
case "Union": {
|
||||
const codeTree = differentiateUnion(ctx, type);
|
||||
|
||||
yield* writeCodeTree(ctx, codeTree, {
|
||||
subject: "input",
|
||||
referenceModelProperty(p) {
|
||||
return "input." + parseCase(p.name).camelCase;
|
||||
},
|
||||
renderResult(type) {
|
||||
return [`return ${transposeExpressionToJson(ctx, type, "input", module)};`];
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function transposeExpressionToJson(
|
||||
ctx: SerializationContext,
|
||||
type: Type,
|
||||
expr: string,
|
||||
module: Module
|
||||
): string {
|
||||
switch (type.kind) {
|
||||
case "Model": {
|
||||
if (isArrayModelType(ctx.program, type)) {
|
||||
const argumentType = type.indexer.value;
|
||||
|
||||
if (requiresJsonSerialization(ctx, argumentType)) {
|
||||
return `${expr}.map((item) => ${transposeExpressionToJson(ctx, argumentType, "item", module)})`;
|
||||
} else {
|
||||
return expr;
|
||||
}
|
||||
} else if (isRecordModelType(ctx.program, type)) {
|
||||
const argumentType = type.indexer.value;
|
||||
|
||||
if (requiresJsonSerialization(ctx, argumentType)) {
|
||||
return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [String(key), ${transposeExpressionToJson(
|
||||
ctx,
|
||||
argumentType,
|
||||
"value",
|
||||
module
|
||||
)}]))`;
|
||||
} else {
|
||||
return expr;
|
||||
}
|
||||
} else if (!requiresJsonSerialization(ctx, type)) {
|
||||
return expr;
|
||||
} else {
|
||||
requireSerialization(ctx, type, "application/json");
|
||||
const typeReference = emitTypeReference(ctx, type, NoTarget, module);
|
||||
|
||||
return `${typeReference}.toJsonObject(${expr})`;
|
||||
}
|
||||
}
|
||||
case "Scalar":
|
||||
return expr;
|
||||
case "Union":
|
||||
if (!requiresJsonSerialization(ctx, type)) {
|
||||
return expr;
|
||||
} else {
|
||||
requireSerialization(ctx, type, "application/json");
|
||||
const typeReference = emitTypeReference(ctx, type, NoTarget, module, {
|
||||
altName: "WeirdUnion",
|
||||
requireDeclaration: true,
|
||||
});
|
||||
|
||||
return `${typeReference}.toJsonObject(${expr})`;
|
||||
}
|
||||
case "ModelProperty":
|
||||
return transposeExpressionToJson(ctx, type.type, expr, module);
|
||||
case "Intrinsic":
|
||||
switch (type.name) {
|
||||
case "void":
|
||||
return "undefined";
|
||||
case "null":
|
||||
return "null";
|
||||
case "ErrorType":
|
||||
compilerAssert(false, "Encountered ErrorType in JSON serialization", type);
|
||||
return expr;
|
||||
case "never":
|
||||
case "unknown":
|
||||
default:
|
||||
// Unhandled intrinsics will have been caught during type construction. We'll ignore this and
|
||||
// just return the expr as-is.
|
||||
return expr;
|
||||
}
|
||||
case "String":
|
||||
case "Number":
|
||||
case "Boolean":
|
||||
return literalToExpr(type);
|
||||
case "Interface":
|
||||
case "Enum":
|
||||
case "EnumMember":
|
||||
case "TemplateParameter":
|
||||
case "Namespace":
|
||||
case "Operation":
|
||||
case "StringTemplate":
|
||||
case "StringTemplateSpan":
|
||||
case "Tuple":
|
||||
case "UnionVariant":
|
||||
case "Function":
|
||||
case "Decorator":
|
||||
case "FunctionParameter":
|
||||
case "Object":
|
||||
case "Projection":
|
||||
case "ScalarConstructor":
|
||||
default:
|
||||
throw new UnimplementedError(`transformJsonExprForType: ${type.kind}`);
|
||||
}
|
||||
}
|
||||
|
||||
function literalToExpr(type: StringLiteral | BooleanLiteral | NumericLiteral): string {
|
||||
switch (type.kind) {
|
||||
case "String":
|
||||
return escapeUnsafeChars(JSON.stringify(type.value));
|
||||
case "Number":
|
||||
case "Boolean":
|
||||
return String(type.value);
|
||||
}
|
||||
}
|
||||
|
||||
function* emitFromJson(
|
||||
ctx: SerializationContext,
|
||||
type: SerializableType,
|
||||
module: Module
|
||||
): Iterable<string> {
|
||||
switch (type.kind) {
|
||||
case "Model": {
|
||||
yield `return {`;
|
||||
|
||||
for (const property of type.properties.values()) {
|
||||
const encodedName =
|
||||
getProjectedName(ctx.program, property, "json") ??
|
||||
resolveEncodedName(ctx.program, property, "application/json") ??
|
||||
property.name;
|
||||
|
||||
const expr = transposeExpressionFromJson(
|
||||
ctx,
|
||||
property.type,
|
||||
`input["${encodedName}"]`,
|
||||
module
|
||||
);
|
||||
|
||||
yield ` ${property.name}: ${expr},`;
|
||||
}
|
||||
|
||||
yield "};";
|
||||
|
||||
return;
|
||||
}
|
||||
case "Scalar": {
|
||||
yield `throw new Error("Unimplemented: scalar JSON serialization");`;
|
||||
return;
|
||||
}
|
||||
case "Union": {
|
||||
const codeTree = differentiateUnion(ctx, type);
|
||||
|
||||
yield* writeCodeTree(ctx, codeTree, {
|
||||
subject: "input",
|
||||
referenceModelProperty(p) {
|
||||
const jsonName =
|
||||
getProjectedName(ctx.program, p, "json") ??
|
||||
resolveEncodedName(ctx.program, p, "application/json") ??
|
||||
p.name;
|
||||
return "input[" + JSON.stringify(jsonName) + "]";
|
||||
},
|
||||
renderResult(type) {
|
||||
return [`return ${transposeExpressionFromJson(ctx, type, "input", module)};`];
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function transposeExpressionFromJson(
|
||||
ctx: SerializationContext,
|
||||
type: Type,
|
||||
expr: string,
|
||||
module: Module
|
||||
): string {
|
||||
switch (type.kind) {
|
||||
case "Model": {
|
||||
if (isArrayModelType(ctx.program, type)) {
|
||||
const argumentType = type.indexer.value;
|
||||
|
||||
if (requiresJsonSerialization(ctx, argumentType)) {
|
||||
return `${expr}.map((item) => ${transposeExpressionFromJson(ctx, argumentType, "item", module)})`;
|
||||
} else {
|
||||
return expr;
|
||||
}
|
||||
} else if (isRecordModelType(ctx.program, type)) {
|
||||
const argumentType = type.indexer.value;
|
||||
|
||||
if (requiresJsonSerialization(ctx, argumentType)) {
|
||||
return `Object.fromEntries(Object.entries(${expr}).map(([key, value]) => [key, ${transposeExpressionFromJson(
|
||||
ctx,
|
||||
argumentType,
|
||||
"value",
|
||||
module
|
||||
)}]))`;
|
||||
} else {
|
||||
return expr;
|
||||
}
|
||||
} else if (!requiresJsonSerialization(ctx, type)) {
|
||||
return `${expr} as ${emitTypeReference(ctx, type, NoTarget, module)}`;
|
||||
} else {
|
||||
requireSerialization(ctx, type, "application/json");
|
||||
const typeReference = emitTypeReference(ctx, type, NoTarget, module);
|
||||
|
||||
return `${typeReference}.fromJsonObject(${expr})`;
|
||||
}
|
||||
}
|
||||
case "Scalar":
|
||||
return expr;
|
||||
case "Union":
|
||||
if (!requiresJsonSerialization(ctx, type)) {
|
||||
return expr;
|
||||
} else {
|
||||
requireSerialization(ctx, type, "application/json");
|
||||
const typeReference = emitTypeReference(ctx, type, NoTarget, module, {
|
||||
altName: "WeirdUnion",
|
||||
requireDeclaration: true,
|
||||
});
|
||||
|
||||
return `${typeReference}.fromJsonObject(${expr})`;
|
||||
}
|
||||
case "ModelProperty":
|
||||
return transposeExpressionFromJson(ctx, type.type, expr, module);
|
||||
case "Intrinsic":
|
||||
switch (type.name) {
|
||||
case "ErrorType":
|
||||
throw new Error("UNREACHABLE: ErrorType in JSON deserialization");
|
||||
case "void":
|
||||
return "undefined";
|
||||
case "null":
|
||||
return "null";
|
||||
case "never":
|
||||
case "unknown":
|
||||
return expr;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unreachable: intrinsic type ${(type satisfies never as IntrinsicType).name}`
|
||||
);
|
||||
}
|
||||
case "String":
|
||||
case "Number":
|
||||
case "Boolean":
|
||||
return literalToExpr(type);
|
||||
case "Interface":
|
||||
case "Enum":
|
||||
case "EnumMember":
|
||||
case "TemplateParameter":
|
||||
case "Namespace":
|
||||
case "Operation":
|
||||
case "StringTemplate":
|
||||
case "StringTemplateSpan":
|
||||
case "Tuple":
|
||||
case "UnionVariant":
|
||||
case "Function":
|
||||
case "Decorator":
|
||||
case "FunctionParameter":
|
||||
case "Object":
|
||||
case "Projection":
|
||||
case "ScalarConstructor":
|
||||
default:
|
||||
throw new UnimplementedError(`transformJsonExprForType: ${type.kind}`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Union, UnionVariant } from "@typespec/compiler";
|
||||
import { JsContext, Module, PartialUnionSynthetic } from "../ctx.js";
|
||||
import { parseCase } from "../util/case.js";
|
||||
import { emitDocumentation } from "./documentation.js";
|
||||
import { emitTypeReference } from "./reference.js";
|
||||
|
||||
/**
|
||||
* Emit an inline union type. This will automatically import any referenced types that are part of the union.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param variants - The variants of the union.
|
||||
* @param module - The module that this union is written into.
|
||||
* @returns a string that can be used as a type reference
|
||||
*/
|
||||
export function emitUnionType(ctx: JsContext, variants: UnionVariant[], module: Module): string {
|
||||
// Treat empty unions as never so that we always return a good type reference here.
|
||||
if (variants.length === 0) return "never";
|
||||
|
||||
const variantTypes: string[] = [];
|
||||
|
||||
for (const [_, v] of variants.entries()) {
|
||||
const name = emitTypeReference(ctx, v.type, v, module);
|
||||
|
||||
variantTypes.push(name);
|
||||
|
||||
// if (isImportableType(ctx, v.type)) {
|
||||
// module.imports.push({
|
||||
// binder: [name],
|
||||
// from: createOrGetModuleForNamespace(ctx, v.type.namespace!),
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
return variantTypes.join(" | ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a union type declaration as an alias.
|
||||
*
|
||||
* This is rare in TypeScript, but may occur in some niche cases where an alias is desirable.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param union - The union to emit.
|
||||
* @param module - The module that this union declaration is written into.
|
||||
* @param altName - An alternative name to use for the union if it is not named.
|
||||
*/
|
||||
export function* emitUnion(
|
||||
ctx: JsContext,
|
||||
union: Union | PartialUnionSynthetic,
|
||||
module: Module,
|
||||
altName?: string
|
||||
): Iterable<string> {
|
||||
const name = union.name ? parseCase(union.name).pascalCase : altName;
|
||||
const isPartialSynthetic = union.kind === "partialUnion";
|
||||
|
||||
if (name === undefined) {
|
||||
throw new Error("Internal Error: Union name is undefined");
|
||||
}
|
||||
|
||||
if (!isPartialSynthetic) yield* emitDocumentation(ctx, union);
|
||||
|
||||
const variants = isPartialSynthetic
|
||||
? union.variants.map((v) => [v.name, v] as const)
|
||||
: union.variants.entries();
|
||||
|
||||
const variantTypes = [...variants].map(([_, v]) =>
|
||||
emitTypeReference(ctx, v.type, v, module, {
|
||||
altName: name + parseCase(String(v.name)).pascalCase,
|
||||
})
|
||||
);
|
||||
|
||||
yield `export type ${name} = ${variantTypes.join(" | ")};`;
|
||||
}
|
|
@ -0,0 +1,386 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import {
|
||||
Enum,
|
||||
Interface,
|
||||
Model,
|
||||
Namespace,
|
||||
Program,
|
||||
Scalar,
|
||||
Service,
|
||||
Type,
|
||||
Union,
|
||||
UnionVariant,
|
||||
compilerAssert,
|
||||
isArrayModelType,
|
||||
isRecordModelType,
|
||||
} from "@typespec/compiler";
|
||||
import { emitDeclaration } from "./common/declaration.js";
|
||||
import { createOrGetModuleForNamespace } from "./common/namespace.js";
|
||||
import { SerializableType } from "./common/serialization/index.js";
|
||||
import { emitUnion } from "./common/union.js";
|
||||
import { JsEmitterOptions } from "./lib.js";
|
||||
import { OnceQueue } from "./util/once-queue.js";
|
||||
|
||||
export type DeclarationType = Model | Enum | Union | Interface | Scalar;
|
||||
|
||||
/**
|
||||
* Determines whether or not a type is importable into a JavaScript module.
|
||||
*
|
||||
* i.e. whether or not it is declared as a named symbol within the module.
|
||||
*
|
||||
* In TypeScript, unions are rendered inline, so they are not ordinarily
|
||||
* considered importable.
|
||||
*
|
||||
* @param ctx - The JS emitter context.
|
||||
* @param t - the type to test
|
||||
* @returns `true` if the type is an importable declaration, `false` otherwise.
|
||||
*/
|
||||
export function isImportableType(ctx: JsContext, t: Type): t is DeclarationType {
|
||||
return (
|
||||
(t.kind === "Model" &&
|
||||
!isArrayModelType(ctx.program, t) &&
|
||||
!isRecordModelType(ctx.program, t)) ||
|
||||
t.kind === "Enum" ||
|
||||
t.kind === "Interface"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores stateful information consumed and modified by the JavaScript server
|
||||
* emitter.
|
||||
*/
|
||||
export interface JsContext {
|
||||
/**
|
||||
* The TypeSpec Program that this emitter instance operates over.
|
||||
*/
|
||||
program: Program;
|
||||
|
||||
/**
|
||||
* The emitter options.
|
||||
*/
|
||||
options: JsEmitterOptions;
|
||||
|
||||
/**
|
||||
* The global (root) namespace of the program.
|
||||
*/
|
||||
globalNamespace: Namespace;
|
||||
|
||||
/**
|
||||
* The service definition to use for emit.
|
||||
*/
|
||||
service: Service;
|
||||
|
||||
/**
|
||||
* A queue of all types to be included in the emit tree. This queue
|
||||
* automatically deduplicates types, so if a type is added multiple times it
|
||||
* will only be visited once.
|
||||
*/
|
||||
typeQueue: OnceQueue<DeclarationType>;
|
||||
/**
|
||||
* A list of synthetic types (anonymous types that are given names) that are
|
||||
* included in the emit tree.
|
||||
*/
|
||||
synthetics: Synthetic[];
|
||||
/**
|
||||
* A cache of names given to synthetic types. These names may be used to avoid
|
||||
* emitting the same synthetic type multiple times.
|
||||
*/
|
||||
syntheticNames: Map<DeclarationType, string>;
|
||||
|
||||
/**
|
||||
* The root module for the emit tree.
|
||||
*/
|
||||
rootModule: Module;
|
||||
|
||||
/**
|
||||
* A map relating each namespace to the module that contains its declarations.
|
||||
*
|
||||
* @see createOrGetModuleForNamespace
|
||||
*/
|
||||
namespaceModules: Map<Namespace, Module>;
|
||||
/**
|
||||
* The module that contains all synthetic types.
|
||||
*/
|
||||
syntheticModule: Module;
|
||||
/**
|
||||
* The root module for all named declarations of types referenced by the program.
|
||||
*/
|
||||
modelsModule: Module;
|
||||
/**
|
||||
* The module within `models` that maps to the global namespace.
|
||||
*/
|
||||
globalNamespaceModule: Module;
|
||||
|
||||
/**
|
||||
* A map of all types that require serialization code to the formats they require.
|
||||
*/
|
||||
serializations: OnceQueue<SerializableType>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A synthetic type that is not directly represented with a name in the TypeSpec program.
|
||||
*/
|
||||
export type Synthetic = AnonymousSynthetic | PartialUnionSynthetic;
|
||||
|
||||
/**
|
||||
* An ordinary, anonymous type that is given a name.
|
||||
*/
|
||||
export interface AnonymousSynthetic {
|
||||
kind: "anonymous";
|
||||
name: string;
|
||||
underlying: DeclarationType;
|
||||
}
|
||||
|
||||
/**
|
||||
* A partial union with a name for the given variants.
|
||||
*/
|
||||
export interface PartialUnionSynthetic {
|
||||
kind: "partialUnion";
|
||||
name: string;
|
||||
variants: UnionVariant[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds all pending declarations from the type queue to the module tree.
|
||||
*
|
||||
* The JavaScript emitter is lazy, and sometimes emitter components may visit
|
||||
* types that are not yet declared. This function ensures that all types
|
||||
* reachable from existing declarations are complete.
|
||||
*
|
||||
* @param ctx - The JavaScript emitter context.
|
||||
*/
|
||||
export function completePendingDeclarations(ctx: JsContext): void {
|
||||
// Add all pending declarations to the module tree.
|
||||
while (!ctx.typeQueue.isEmpty() || ctx.synthetics.length > 0) {
|
||||
while (!ctx.typeQueue.isEmpty()) {
|
||||
const type = ctx.typeQueue.take()!;
|
||||
|
||||
compilerAssert(type.namespace !== undefined, "no namespace for declaration type", type);
|
||||
|
||||
const module = createOrGetModuleForNamespace(ctx, type.namespace);
|
||||
|
||||
module.declarations.push([...emitDeclaration(ctx, type, module)]);
|
||||
}
|
||||
|
||||
while (ctx.synthetics.length > 0) {
|
||||
const synthetic = ctx.synthetics.shift()!;
|
||||
|
||||
switch (synthetic.kind) {
|
||||
case "anonymous": {
|
||||
ctx.syntheticModule.declarations.push([
|
||||
...emitDeclaration(ctx, synthetic.underlying, ctx.syntheticModule, synthetic.name),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
case "partialUnion": {
|
||||
ctx.syntheticModule.declarations.push([
|
||||
...emitUnion(ctx, synthetic, ctx.syntheticModule, synthetic.name),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #region Module
|
||||
|
||||
/**
|
||||
* A declaration within a module. This may be a string (i.e. a line), an array of
|
||||
* strings (emitted as multiple lines), or another module (emitted as a nested module).
|
||||
*/
|
||||
export type ModuleBodyDeclaration = string[] | string | Module;
|
||||
|
||||
/**
|
||||
* A type-guard that checks whether or not a given value is a module.
|
||||
* @returns `true` if the value is a module, `false` otherwise.
|
||||
*/
|
||||
export function isModule(value: unknown): value is Module {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"declarations" in value &&
|
||||
Array.isArray(value.declarations)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new module with the given name and attaches it to the parent module.
|
||||
*
|
||||
* Optionally, a namespace may be associated with the module. This namespace is
|
||||
* _NOT_ stored in the context (this function does not use the JsContext), and
|
||||
* is only stored as metadata within the module. To associate a module with a
|
||||
* namespace inside the context, use `createOrGetModuleForNamespace`.
|
||||
*
|
||||
* The module is automatically declared as a declaration within its parent
|
||||
* module.
|
||||
*
|
||||
* @param name - The name of the module.
|
||||
* @param parent - The parent module to attach the new module to.
|
||||
* @param namespace - an optional TypeSpec Namespace to associate with the module
|
||||
* @returns the newly created module
|
||||
*/
|
||||
export function createModule(name: string, parent: Module, namespace?: Namespace): Module {
|
||||
const self = {
|
||||
name,
|
||||
cursor: parent.cursor.enter(name),
|
||||
namespace,
|
||||
|
||||
imports: [],
|
||||
declarations: [],
|
||||
};
|
||||
|
||||
parent.declarations.push(self);
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of a binding for an import statement. Either:
|
||||
*
|
||||
* - A string beginning with `* as` followed by the name of the binding, which
|
||||
* imports all exports from the module as a single object.
|
||||
* - An array of strings, each of which is a named import from the module.
|
||||
*/
|
||||
export type ImportBinder = `* as ${string}` | string[];
|
||||
|
||||
/**
|
||||
* An object representing a ECMAScript module import declaration.
|
||||
*/
|
||||
export interface Import {
|
||||
/**
|
||||
* The binder to define the import as.
|
||||
*/
|
||||
binder: ImportBinder;
|
||||
/**
|
||||
* Where to import from. This is either a literal string (which will be used verbatim), or Module object, which will
|
||||
* be resolved to a relative file path.
|
||||
*/
|
||||
from: Module | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An output module within the module tree.
|
||||
*/
|
||||
export interface Module {
|
||||
/**
|
||||
* The name of the module, which should be suitable for use as the basename of
|
||||
* a file and as an identifier.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The cursor for the module, which assists navigation and relative path
|
||||
* computation between modules.
|
||||
*/
|
||||
readonly cursor: PathCursor;
|
||||
|
||||
/**
|
||||
* An optional namespace for the module. This is not used by the code writer,
|
||||
* but is used to track dependencies between TypeSpec namespaces and create
|
||||
* imports between them.
|
||||
*/
|
||||
namespace?: Namespace;
|
||||
|
||||
/**
|
||||
* A list of imports that the module requires.
|
||||
*/
|
||||
imports: Import[];
|
||||
|
||||
/**
|
||||
* A list of declarations within the module.
|
||||
*/
|
||||
declarations: ModuleBodyDeclaration[];
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* A cursor that assists in navigating the module tree and computing relative
|
||||
* paths between modules.
|
||||
*/
|
||||
export interface PathCursor {
|
||||
/**
|
||||
* The path to this cursor. This is an array of strings that represents the
|
||||
* path from the root module to another module.
|
||||
*/
|
||||
readonly path: string[];
|
||||
|
||||
/**
|
||||
* The parent cursor of this cursor (equivalent to moving up one level in the
|
||||
* module tree). If this cursor is the root cursor, this property is `undefined`.
|
||||
*/
|
||||
readonly parent: PathCursor | undefined;
|
||||
|
||||
/**
|
||||
* Returns a new cursor that includes the given path components appended to
|
||||
* this cursor's path.
|
||||
*
|
||||
* @param path - the path to append to this cursor
|
||||
*/
|
||||
enter(...path: string[]): PathCursor;
|
||||
|
||||
/**
|
||||
* Computes a relative path from this cursor to another cursor, using the string `up`
|
||||
* to navigate upwards one level in the path. This is similar to `path.relative` when
|
||||
* working with file paths, but operates over PathCursor objects.
|
||||
*
|
||||
* @param to - the cursor to compute the path to
|
||||
* @param up - the string to use to move up a level in the path (defaults to "..")
|
||||
*/
|
||||
relativePath(to: PathCursor, up?: string): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new cursor with the given path.
|
||||
*
|
||||
* @param base - the base path of this cursor
|
||||
* @returns
|
||||
*/
|
||||
export function createPathCursor(...base: string[]): PathCursor {
|
||||
const self: PathCursor = {
|
||||
path: base,
|
||||
|
||||
get parent() {
|
||||
return self.path.length === 0 ? undefined : createPathCursor(...self.path.slice(0, -1));
|
||||
},
|
||||
|
||||
enter(...path: string[]) {
|
||||
return createPathCursor(...self.path, ...path);
|
||||
},
|
||||
|
||||
relativePath(to: PathCursor, up: string = ".."): string[] {
|
||||
const commonPrefix = getCommonPrefix(self.path, to.path);
|
||||
|
||||
const outputPath = [];
|
||||
|
||||
for (let i = 0; i < self.path.length - commonPrefix.length; i++) {
|
||||
outputPath.push(up);
|
||||
}
|
||||
|
||||
outputPath.push(...to.path.slice(commonPrefix.length));
|
||||
|
||||
return outputPath;
|
||||
},
|
||||
};
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the common prefix of two paths.
|
||||
*/
|
||||
function getCommonPrefix(a: string[], b: string[]): string[] {
|
||||
const prefix = [];
|
||||
|
||||
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
||||
if (a[i] !== b[i]) {
|
||||
break;
|
||||
}
|
||||
|
||||
prefix.push(a[i]);
|
||||
}
|
||||
|
||||
return prefix;
|
||||
}
|
|
@ -0,0 +1,243 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import type * as http from "node:http";
|
||||
|
||||
/** A policy that can be applied to a route or a set of routes. */
|
||||
export interface Policy {
|
||||
/** Optional policy name. */
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Applies the policy to the request.
|
||||
*
|
||||
* Policies _MUST_ call `next()` to pass the request to the next policy _OR_ call `response.end()` to terminate,
|
||||
* and _MUST NOT_ do both.
|
||||
*
|
||||
* If the policy passes a `request` object to `next()`, that request object will be used instead of the original
|
||||
* request object for the remainder of the policy chain. If the policy does _not_ pass a request object to `next()`,
|
||||
* the same object that was passed to this policy will be forwarded to the next policy automatically.
|
||||
*
|
||||
* @param request - The incoming HTTP request.
|
||||
* @param response - The outgoing HTTP response.
|
||||
* @param next - Calls the next policy in the chain.
|
||||
*/
|
||||
(
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse,
|
||||
next: (request?: http.IncomingMessage) => void
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a function from a chain of policies.
|
||||
*
|
||||
* This returns a single function that will apply the policy chain and eventually call the provided `next()` function.
|
||||
*
|
||||
* @param name - The name to give to the policy chain function.
|
||||
* @param policies - The policies to apply to the request.
|
||||
* @param out - The function to call after the policies have been applied.
|
||||
*/
|
||||
export function createPolicyChain<
|
||||
Out extends (
|
||||
ctx: HttpContext,
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse,
|
||||
...rest: any[]
|
||||
) => void,
|
||||
>(name: string, policies: Policy[], out: Out): Out {
|
||||
let outParams: any[];
|
||||
if (policies.length === 0) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyPolicy(
|
||||
ctx: HttpContext,
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse,
|
||||
index: number
|
||||
) {
|
||||
if (index >= policies.length) {
|
||||
return out(ctx, request, response, ...outParams);
|
||||
}
|
||||
|
||||
policies[index](request, response, function nextPolicy(nextRequest) {
|
||||
applyPolicy(ctx, nextRequest ?? request, response, index + 1);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
[name](
|
||||
ctx: HttpContext,
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse,
|
||||
...params: any[]
|
||||
) {
|
||||
outParams = params;
|
||||
applyPolicy(ctx, request, response, 0);
|
||||
},
|
||||
}[name] as Out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type of an error encountered during request validation.
|
||||
*/
|
||||
export type ValidationError = string;
|
||||
|
||||
/**
|
||||
* An object specifying the policies for a given route configuration.
|
||||
*/
|
||||
export type RoutePolicies<RouteConfig extends { [k: string]: object }> = {
|
||||
[Interface in keyof RouteConfig]?: {
|
||||
before?: Policy[];
|
||||
after?: Policy[];
|
||||
methodPolicies?: {
|
||||
[Method in keyof RouteConfig[Interface]]?: Policy[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a policy chain for a given route.
|
||||
*
|
||||
* This function calls `createPolicyChain` internally and orders the policies based on the route configuration.
|
||||
*
|
||||
* Interface-level `before` policies run first, then method-level policies, then Interface-level `after` policies.
|
||||
*
|
||||
* @param name - The name to give to the policy chain function.
|
||||
* @param routePolicies - The policies to apply to the routes (part of the route configuration).
|
||||
* @param interfaceName - The name of the interface that the route belongs to.
|
||||
* @param methodName - The name of the method that the route corresponds to.
|
||||
* @param out - The function to call after the policies have been applied.
|
||||
*/
|
||||
export function createPolicyChainForRoute<
|
||||
RouteConfig extends { [k: string]: object },
|
||||
InterfaceName extends keyof RouteConfig,
|
||||
Out extends (
|
||||
ctx: HttpContext,
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse,
|
||||
...rest: any[]
|
||||
) => void,
|
||||
>(
|
||||
name: string,
|
||||
routePolicies: RoutePolicies<RouteConfig>,
|
||||
interfaceName: InterfaceName,
|
||||
methodName: keyof RouteConfig[InterfaceName],
|
||||
out: Out
|
||||
): Out {
|
||||
return createPolicyChain(
|
||||
name,
|
||||
[
|
||||
...(routePolicies[interfaceName]?.before ?? []),
|
||||
...(routePolicies[interfaceName]?.methodPolicies?.[methodName] ?? []),
|
||||
...(routePolicies[interfaceName]?.after ?? []),
|
||||
],
|
||||
out
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for configuring a router with additional functionality.
|
||||
*/
|
||||
export interface RouterOptions<
|
||||
RouteConfig extends { [k: string]: object } = { [k: string]: object },
|
||||
> {
|
||||
/**
|
||||
* The base path of the router.
|
||||
*
|
||||
* This should include any leading slashes, but not a trailing slash, and should not include any component
|
||||
* of the URL authority (e.g. the scheme, host, or port).
|
||||
*
|
||||
* Defaults to "".
|
||||
*/
|
||||
basePath?: string;
|
||||
|
||||
/**
|
||||
* A list of policies to apply to all routes _before_ routing.
|
||||
*
|
||||
* Policies are applied in the order they are listed.
|
||||
*
|
||||
* By default, the policy list is empty.
|
||||
*
|
||||
* Policies _MUST_ call `next()` to pass the request to the next policy _OR_ call `response.end()` to terminate
|
||||
* the response and _MUST NOT_ do both.
|
||||
*/
|
||||
policies?: Policy[];
|
||||
|
||||
/**
|
||||
* A record of policies that apply to specific routes.
|
||||
*
|
||||
* The policies are provided as a nested record where the keys are the business-logic interface names, and the values
|
||||
* are records of the method names in the given interface and the policies that apply to them.
|
||||
*
|
||||
* By default, no additional policies are applied to the routes.
|
||||
*
|
||||
* Policies _MUST_ call `next()` to pass the request to the next policy _OR_ call `response.end()` to terminate
|
||||
* the response and _MUST NOT_ do both.
|
||||
*/
|
||||
routePolicies?: RoutePolicies<RouteConfig>;
|
||||
|
||||
/**
|
||||
* A handler for requests that do not match any known route and method.
|
||||
*
|
||||
* If this handler is not provided, a 404 Not Found response with a text body will be returned.
|
||||
*
|
||||
* You _MUST_ call `response.end()` to terminate the response.
|
||||
*
|
||||
* This handler is unreachable when using the Express middleware, as it will forward non-matching requests to the
|
||||
* next middleware layer in the stack.
|
||||
*
|
||||
* @param request - The incoming HTTP request.
|
||||
* @param response - The outgoing HTTP response.
|
||||
*/
|
||||
onRequestNotFound?: (request: http.IncomingMessage, response: http.ServerResponse) => void;
|
||||
|
||||
/**
|
||||
* A handler for requests that fail to validate inputs.
|
||||
*
|
||||
* If this handler is not provided, a 400 Bad Request response with a JSON body containing some basic information
|
||||
* about the error will be returned to the client.
|
||||
*
|
||||
* You _MUST_ call `response.end()` to terminate the response.
|
||||
*
|
||||
* @param request - The incoming HTTP request.
|
||||
* @param response - The outgoing HTTP response.
|
||||
* @param route - The route that was matched.
|
||||
* @param error - The validation error that was thrown.
|
||||
*/
|
||||
onInvalidRequest?: (
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse,
|
||||
route: string,
|
||||
error: ValidationError
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* A handler for requests that throw an error during processing.
|
||||
*
|
||||
* If this handler is not provided, a 500 Internal Server Error response with a text body and no error details will be
|
||||
* returned to the client.
|
||||
*
|
||||
* You _MUST_ call `response.end()` to terminate the response.
|
||||
*
|
||||
* If this handler itself throws an Error, the router will respond with a 500 Internal Server Error
|
||||
*
|
||||
* @param error - The error that was thrown.
|
||||
* @param request - The incoming HTTP request.
|
||||
* @param response - The outgoing HTTP response.
|
||||
*/
|
||||
onInternalError?(
|
||||
error: unknown,
|
||||
request: http.IncomingMessage,
|
||||
response: http.ServerResponse
|
||||
): void;
|
||||
}
|
||||
|
||||
/** Context information for operations carried over the HTTP protocol. */
|
||||
export interface HttpContext {
|
||||
/** The incoming request to the server. */
|
||||
request: http.IncomingMessage;
|
||||
/** The outgoing response object. */
|
||||
response: http.ServerResponse;
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { NoTarget } from "@typespec/compiler";
|
||||
import { HttpServer, HttpService, getHttpService, getServers } from "@typespec/http";
|
||||
import { JsContext, Module, createModule } from "../ctx.js";
|
||||
import { reportDiagnostic } from "../lib.js";
|
||||
import { emitRawServer } from "./server/index.js";
|
||||
import { emitRouter } from "./server/router.js";
|
||||
|
||||
/**
|
||||
* Additional context items used by the HTTP emitter.
|
||||
*/
|
||||
export interface HttpContext extends JsContext {
|
||||
/**
|
||||
* The HTTP-level representation of the service.
|
||||
*/
|
||||
httpService: HttpService;
|
||||
/**
|
||||
* The root module for HTTP-specific code.
|
||||
*/
|
||||
httpModule: Module;
|
||||
/**
|
||||
* The server definitions of the service (\@server decorator)
|
||||
*/
|
||||
servers: HttpServer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits bindings for the service to be carried over the HTTP protocol.
|
||||
*/
|
||||
export async function emitHttp(ctx: JsContext) {
|
||||
const [httpService, diagnostics] = getHttpService(ctx.program, ctx.service.type);
|
||||
|
||||
const diagnosticsAreError = diagnostics.some((d) => d.severity === "error");
|
||||
|
||||
if (diagnosticsAreError) {
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "http-emit-disabled",
|
||||
target: NoTarget,
|
||||
messageId: "default",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const servers = getServers(ctx.program, ctx.service.type) ?? [];
|
||||
|
||||
const httpModule = createModule("http", ctx.rootModule);
|
||||
|
||||
const httpContext: HttpContext = {
|
||||
...ctx,
|
||||
httpService,
|
||||
httpModule,
|
||||
servers,
|
||||
};
|
||||
|
||||
const operationsModule = createModule("operations", httpModule);
|
||||
|
||||
const serverRawModule = emitRawServer(httpContext, operationsModule);
|
||||
emitRouter(httpContext, httpService, serverRawModule);
|
||||
}
|
|
@ -0,0 +1,434 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { ModelProperty, Type } from "@typespec/compiler";
|
||||
import {
|
||||
HttpOperation,
|
||||
HttpOperationParameter,
|
||||
getHeaderFieldName,
|
||||
isBody,
|
||||
isHeader,
|
||||
isStatusCode,
|
||||
} from "@typespec/http";
|
||||
import { createOrGetModuleForNamespace } from "../../common/namespace.js";
|
||||
import { emitTypeReference, isValueLiteralType } from "../../common/reference.js";
|
||||
import { parseTemplateForScalar } from "../../common/scalar.js";
|
||||
import {
|
||||
SerializableType,
|
||||
isSerializationRequired,
|
||||
requireSerialization,
|
||||
} from "../../common/serialization/index.js";
|
||||
import { Module, completePendingDeclarations, createModule } from "../../ctx.js";
|
||||
import { parseCase } from "../../util/case.js";
|
||||
import { UnimplementedError } from "../../util/error.js";
|
||||
import { getAllProperties } from "../../util/extends.js";
|
||||
import { bifilter, indent } from "../../util/iter.js";
|
||||
import { keywordSafe } from "../../util/keywords.js";
|
||||
import { HttpContext } from "../index.js";
|
||||
|
||||
import { module as routerHelpers } from "../../../generated-defs/helpers/router.js";
|
||||
import { differentiateUnion, writeCodeTree } from "../../util/differentiate.js";
|
||||
|
||||
const DEFAULT_CONTENT_TYPE = "application/json";
|
||||
|
||||
/**
|
||||
* Emits raw operations for handling incoming server requests.
|
||||
*
|
||||
* @param ctx - The HTTP emitter context.
|
||||
* @param operationsModule - The module to emit the operations into.
|
||||
* @returns the module containing the raw server operations.
|
||||
*/
|
||||
export function emitRawServer(ctx: HttpContext, operationsModule: Module): Module {
|
||||
const serverRawModule = createModule("server-raw", operationsModule);
|
||||
|
||||
serverRawModule.imports.push({
|
||||
binder: "* as http",
|
||||
from: "node:http",
|
||||
});
|
||||
|
||||
serverRawModule.imports.push({
|
||||
binder: ["HttpContext"],
|
||||
from: routerHelpers,
|
||||
});
|
||||
|
||||
for (const operation of ctx.httpService.operations) {
|
||||
serverRawModule.declarations.push([...emitRawServerOperation(ctx, operation, serverRawModule)]);
|
||||
}
|
||||
|
||||
return serverRawModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a raw operation handler for a specific operation.
|
||||
* @param ctx - The HTTP emitter context.
|
||||
* @param operation - The operation to create a handler for.
|
||||
* @param module - The module that the handler will be written to.
|
||||
*/
|
||||
function* emitRawServerOperation(
|
||||
ctx: HttpContext,
|
||||
operation: HttpOperation,
|
||||
module: Module
|
||||
): Iterable<string> {
|
||||
const op = operation.operation;
|
||||
const operationNameCase = parseCase(op.name);
|
||||
|
||||
const container = op.interface ?? op.namespace!;
|
||||
const containerNameCase = parseCase(container.name);
|
||||
|
||||
module.imports.push({
|
||||
binder: [containerNameCase.pascalCase],
|
||||
from: createOrGetModuleForNamespace(ctx, container.namespace!),
|
||||
});
|
||||
|
||||
completePendingDeclarations(ctx);
|
||||
|
||||
const pathParameters = operation.parameters.parameters.filter(function isPathParameter(param) {
|
||||
return param.type === "path";
|
||||
}) as Extract<HttpOperationParameter, { type: "path" }>[];
|
||||
|
||||
const functionName = keywordSafe(containerNameCase.snakeCase + "_" + operationNameCase.snakeCase);
|
||||
|
||||
yield `export async function ${functionName}(`;
|
||||
yield ` ctx: HttpContext,`;
|
||||
yield ` request: http.IncomingMessage,`;
|
||||
yield ` response: http.ServerResponse,`;
|
||||
yield ` operations: ${containerNameCase.pascalCase},`;
|
||||
|
||||
for (const pathParam of pathParameters) {
|
||||
yield ` ${parseCase(pathParam.param.name).camelCase}: string,`;
|
||||
}
|
||||
|
||||
yield "): Promise<void> {";
|
||||
|
||||
const [_, parameters] = bifilter(op.parameters.properties.values(), (param) =>
|
||||
isValueLiteralType(param.type)
|
||||
);
|
||||
|
||||
const queryParams: Extract<HttpOperationParameter, { type: "query" }>[] = [];
|
||||
|
||||
const parsedParams = new Set<ModelProperty>();
|
||||
|
||||
for (const parameter of operation.parameters.parameters) {
|
||||
const resolvedParameter =
|
||||
parameter.param.type.kind === "ModelProperty" ? parameter.param.type : parameter.param;
|
||||
switch (parameter.type) {
|
||||
case "header":
|
||||
yield* indent(emitHeaderParamBinding(ctx, parameter));
|
||||
break;
|
||||
case "query":
|
||||
queryParams.push(parameter);
|
||||
parsedParams.add(resolvedParameter);
|
||||
break;
|
||||
case "path":
|
||||
// Already handled above.
|
||||
parsedParams.add(resolvedParameter);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`UNREACHABLE: parameter type ${
|
||||
(parameter satisfies never as HttpOperationParameter).type
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (queryParams.length > 0) {
|
||||
yield ` const __query_params = new URLSearchParams(request.url!.split("?", 1)[1] ?? "");`;
|
||||
yield "";
|
||||
}
|
||||
|
||||
for (const qp of queryParams) {
|
||||
yield* indent(emitQueryParamBinding(ctx, qp));
|
||||
}
|
||||
|
||||
const bodyFields = new Map<string, Type>(
|
||||
operation.parameters.body && operation.parameters.body.type.kind === "Model"
|
||||
? getAllProperties(operation.parameters.body.type).map((p) => [p.name, p.type] as const)
|
||||
: []
|
||||
);
|
||||
|
||||
let bodyName: string | undefined = undefined;
|
||||
|
||||
if (operation.parameters.body) {
|
||||
const body = operation.parameters.body;
|
||||
|
||||
if (body.contentTypes.length > 1) {
|
||||
throw new UnimplementedError("dynamic request content type");
|
||||
}
|
||||
|
||||
const contentType = body.contentTypes[0] ?? DEFAULT_CONTENT_TYPE;
|
||||
|
||||
const defaultBodyTypeName = operationNameCase.pascalCase + "RequestBody";
|
||||
|
||||
if (body.bodyKind === "multipart") {
|
||||
throw new UnimplementedError(`new form of multipart requests`);
|
||||
}
|
||||
|
||||
const bodyNameCase = parseCase(body.property?.name ?? defaultBodyTypeName);
|
||||
|
||||
const bodyTypeName = emitTypeReference(
|
||||
ctx,
|
||||
body.type,
|
||||
body.property?.type ?? operation.operation.node,
|
||||
module,
|
||||
{ altName: defaultBodyTypeName }
|
||||
);
|
||||
|
||||
bodyName = bodyNameCase.camelCase;
|
||||
|
||||
yield ` if (!request.headers["content-type"]?.startsWith(${JSON.stringify(contentType)})) {`;
|
||||
yield ` throw new Error(\`Invalid Request: expected content-type '${contentType}' but got '\${request.headers["content-type"]?.split(";", 2)[0]}'.\`)`;
|
||||
yield " }";
|
||||
yield "";
|
||||
|
||||
switch (contentType) {
|
||||
case "application/merge-patch+json":
|
||||
case "application/json": {
|
||||
requireSerialization(ctx, body.type as SerializableType, "application/json");
|
||||
yield ` const ${bodyName} = await new Promise(function parse${bodyNameCase.pascalCase}(resolve, reject) {`;
|
||||
yield ` const chunks: Array<Buffer> = [];`;
|
||||
yield ` request.on("data", function appendChunk(chunk) { chunks.push(chunk); });`;
|
||||
yield ` request.on("end", function finalize() {`;
|
||||
yield ` resolve(JSON.parse(Buffer.concat(chunks).toString()));`;
|
||||
yield ` });`;
|
||||
yield ` }) as ${bodyTypeName};`;
|
||||
yield "";
|
||||
|
||||
break;
|
||||
}
|
||||
case "multipart/form-data":
|
||||
yield `const ${bodyName} = await new Promise(function parse${bodyNameCase.pascalCase}MultipartRequest(resolve, reject) {`;
|
||||
yield ` const boundary = request.headers["content-type"]?.split(";").find((s) => s.includes("boundary="))?.split("=", 2)[1];`;
|
||||
yield ` if (!boundary) {`;
|
||||
yield ` return reject("Invalid request: missing boundary in content-type.");`;
|
||||
yield ` }`;
|
||||
yield "";
|
||||
yield ` const chunks: Array<Buffer> = [];`;
|
||||
yield ` request.on("data", function appendChunk(chunk) { chunks.push(chunk); });`;
|
||||
yield ` request.on("end", function finalize() {`;
|
||||
yield ` const text = Buffer.concat(chunks).toString();`;
|
||||
yield ` const parts = text.split(boundary).slice(1, -1);`;
|
||||
yield ` const fields: { [k: string]: any } = {};`;
|
||||
yield "";
|
||||
yield ` for (const part of parts) {`;
|
||||
yield ` const [headerText, body] = part.split("\\r\\n\\r\\n", 2);`;
|
||||
yield " const headers = Object.fromEntries(";
|
||||
yield ` headerText.split("\\r\\n").map((line) => line.split(": ", 2))`;
|
||||
yield " ) as { [k: string]: string };";
|
||||
yield ` const name = headers["Content-Disposition"].split("name=\\"")[1].split("\\"")[0];`;
|
||||
yield ` const contentType = headers["Content-Type"] ?? "text/plain";`;
|
||||
yield "";
|
||||
yield ` switch (contentType) {`;
|
||||
yield ` case "application/json":`;
|
||||
yield ` fields[name] = JSON.parse(body);`;
|
||||
yield ` break;`;
|
||||
yield ` case "application/octet-stream":`;
|
||||
yield ` fields[name] = Buffer.from(body, "utf-8");`;
|
||||
yield ` break;`;
|
||||
yield ` default:`;
|
||||
yield ` fields[name] = body;`;
|
||||
yield ` }`;
|
||||
yield ` }`;
|
||||
yield "";
|
||||
yield ` resolve(fields as ${bodyTypeName});`;
|
||||
yield ` });`;
|
||||
yield `}) as ${bodyTypeName};`;
|
||||
break;
|
||||
default:
|
||||
throw new UnimplementedError(`request deserialization for content-type: '${contentType}'`);
|
||||
}
|
||||
|
||||
yield "";
|
||||
}
|
||||
|
||||
let hasOptions = false;
|
||||
const optionalParams = new Map<string, string>();
|
||||
|
||||
const requiredParams = [];
|
||||
|
||||
for (const param of parameters) {
|
||||
let paramBaseExpression;
|
||||
const paramNameCase = parseCase(param.name);
|
||||
const isBodyField = bodyFields.has(param.name) && bodyFields.get(param.name) === param.type;
|
||||
if (isBodyField) {
|
||||
paramBaseExpression = `${bodyName}.${paramNameCase.camelCase}`;
|
||||
} else {
|
||||
const resolvedParameter = param.type.kind === "ModelProperty" ? param.type : param;
|
||||
|
||||
paramBaseExpression =
|
||||
resolvedParameter.type.kind === "Scalar" && parsedParams.has(resolvedParameter)
|
||||
? parseTemplateForScalar(ctx, resolvedParameter.type).replace(
|
||||
"{}",
|
||||
paramNameCase.camelCase
|
||||
)
|
||||
: paramNameCase.camelCase;
|
||||
}
|
||||
|
||||
if (param.optional) {
|
||||
hasOptions = true;
|
||||
optionalParams.set(paramNameCase.camelCase, paramBaseExpression);
|
||||
} else {
|
||||
requiredParams.push(paramBaseExpression);
|
||||
}
|
||||
}
|
||||
|
||||
const paramLines = requiredParams.map((p) => `${p},`);
|
||||
|
||||
if (hasOptions) {
|
||||
paramLines.push(
|
||||
`{ ${[...optionalParams.entries()].map(([name, expr]) => `${name}: ${expr},`)} }`
|
||||
);
|
||||
}
|
||||
|
||||
yield ` const result = await operations.${operationNameCase.camelCase}(ctx, `;
|
||||
yield* indent(indent(paramLines));
|
||||
yield ` );`, yield "";
|
||||
|
||||
yield* indent(emitResultProcessing(ctx, op.returnType, module));
|
||||
|
||||
yield "}";
|
||||
|
||||
yield "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the result-processing code for an operation.
|
||||
*
|
||||
* This code handles writing the result of calling the business logic layer to the HTTP response object.
|
||||
*
|
||||
* @param ctx - The HTTP emitter context.
|
||||
* @param t - The return type of the operation.
|
||||
* @param module - The module that the result processing code will be written to.
|
||||
*/
|
||||
function* emitResultProcessing(ctx: HttpContext, t: Type, module: Module): Iterable<string> {
|
||||
if (t.kind !== "Union") {
|
||||
// Single target type
|
||||
yield* emitResultProcessingForType(ctx, t, module);
|
||||
} else {
|
||||
const codeTree = differentiateUnion(ctx, t);
|
||||
|
||||
yield* writeCodeTree(ctx, codeTree, {
|
||||
subject: "result",
|
||||
referenceModelProperty(p) {
|
||||
return "result." + parseCase(p.name).camelCase;
|
||||
},
|
||||
// We mapped the output directly in the code tree input, so we can just return it.
|
||||
renderResult: (t) => emitResultProcessingForType(ctx, t, module),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the result-processing code for a single response type.
|
||||
*
|
||||
* @param ctx - The HTTP emitter context.
|
||||
* @param target - The target type to emit processing code for.
|
||||
* @param module - The module that the result processing code will be written to.
|
||||
*/
|
||||
function* emitResultProcessingForType(
|
||||
ctx: HttpContext,
|
||||
target: Type,
|
||||
module: Module
|
||||
): Iterable<string> {
|
||||
if (target.kind !== "Model") {
|
||||
throw new UnimplementedError(`result processing for type kind '${target.kind}'`);
|
||||
}
|
||||
|
||||
const body = [...target.properties.values()].find((p) => isBody(ctx.program, p));
|
||||
|
||||
for (const property of target.properties.values()) {
|
||||
if (isHeader(ctx.program, property)) {
|
||||
const headerName = getHeaderFieldName(ctx.program, property);
|
||||
yield `response.setHeader(${JSON.stringify(headerName.toLowerCase())}, result.${parseCase(property.name).camelCase});`;
|
||||
if (!body) yield `delete (result as any).${parseCase(property.name).camelCase};`;
|
||||
} else if (isStatusCode(ctx.program, property)) {
|
||||
yield `response.statusCode = result.${parseCase(property.name).camelCase};`;
|
||||
if (!body) yield `delete (result as any).${parseCase(property.name).camelCase};`;
|
||||
}
|
||||
}
|
||||
|
||||
const allMetadataIsRemoved =
|
||||
!body &&
|
||||
[...target.properties.values()].every((p) => {
|
||||
return isHeader(ctx.program, p) || isStatusCode(ctx.program, p);
|
||||
});
|
||||
|
||||
if (body) {
|
||||
const bodyCase = parseCase(body.name);
|
||||
const serializationRequired = isSerializationRequired(ctx, body.type, "application/json");
|
||||
requireSerialization(ctx, body.type, "application/json");
|
||||
if (serializationRequired) {
|
||||
const typeReference = emitTypeReference(ctx, body.type, body, module, {
|
||||
requireDeclaration: true,
|
||||
});
|
||||
yield `response.end(JSON.stringify(${typeReference}.toJsonObject(result.${bodyCase.camelCase})))`;
|
||||
} else {
|
||||
yield `response.end(JSON.stringify(result.${bodyCase.camelCase}));`;
|
||||
}
|
||||
} else {
|
||||
if (allMetadataIsRemoved) {
|
||||
yield `response.end();`;
|
||||
} else {
|
||||
const serializationRequired = isSerializationRequired(ctx, target, "application/json");
|
||||
requireSerialization(ctx, target, "application/json");
|
||||
if (serializationRequired) {
|
||||
const typeReference = emitTypeReference(ctx, target, target, module, {
|
||||
requireDeclaration: true,
|
||||
});
|
||||
yield `response.end(JSON.stringify(${typeReference}.toJsonObject(result as ${typeReference})));`;
|
||||
} else {
|
||||
yield `response.end(JSON.stringify(result));`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit code that binds a given header parameter to a variable.
|
||||
*
|
||||
* If the parameter is not optional, this will also emit a test to ensure that the parameter is present.
|
||||
*
|
||||
* @param ctx - The HTTP emitter context.
|
||||
* @param parameter - The header parameter to bind.
|
||||
*/
|
||||
function* emitHeaderParamBinding(
|
||||
ctx: HttpContext,
|
||||
parameter: Extract<HttpOperationParameter, { type: "header" }>
|
||||
): Iterable<string> {
|
||||
const nameCase = parseCase(parameter.param.name);
|
||||
|
||||
yield `const ${nameCase.camelCase} = request.headers[${JSON.stringify(parameter.name)}];`;
|
||||
|
||||
if (!parameter.param.optional) {
|
||||
yield `if (${nameCase.camelCase} === undefined) {`;
|
||||
// prettier-ignore
|
||||
yield ` throw new Error("Invalid request: missing required header '${parameter.name}'.");`;
|
||||
yield "}";
|
||||
yield "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit code that binds a given query parameter to a variable.
|
||||
*
|
||||
* If the parameter is not optional, this will also emit a test to ensure that the parameter is present.
|
||||
*
|
||||
* @param ctx - The HTTP emitter context
|
||||
* @param parameter - The query parameter to bind
|
||||
*/
|
||||
function* emitQueryParamBinding(
|
||||
ctx: HttpContext,
|
||||
parameter: Extract<HttpOperationParameter, { type: "query" }>
|
||||
): Iterable<string> {
|
||||
const nameCase = parseCase(parameter.param.name);
|
||||
|
||||
yield `const ${nameCase.camelCase} = __query_params.get(${JSON.stringify(parameter.name)});`;
|
||||
|
||||
if (!parameter.param.optional) {
|
||||
yield `if (${nameCase.camelCase} === null) {`;
|
||||
// prettier-ignore
|
||||
yield ` throw new Error("Invalid request: missing required query parameter '${parameter.name}'.");`;
|
||||
yield "}";
|
||||
yield "";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,632 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Operation, Type } from "@typespec/compiler";
|
||||
import {
|
||||
HttpOperation,
|
||||
HttpService,
|
||||
HttpVerb,
|
||||
OperationContainer,
|
||||
getHttpOperation,
|
||||
} from "@typespec/http";
|
||||
import {
|
||||
createOrGetModuleForNamespace,
|
||||
emitNamespaceInterfaceReference,
|
||||
} from "../../common/namespace.js";
|
||||
import { emitTypeReference } from "../../common/reference.js";
|
||||
import { Module, createModule } from "../../ctx.js";
|
||||
import { ReCase, parseCase } from "../../util/case.js";
|
||||
import { bifilter, indent } from "../../util/iter.js";
|
||||
import { keywordSafe } from "../../util/keywords.js";
|
||||
import { HttpContext } from "../index.js";
|
||||
|
||||
import { module as routerHelper } from "../../../generated-defs/helpers/router.js";
|
||||
import { reportDiagnostic } from "../../lib.js";
|
||||
import { UnimplementedError } from "../../util/error.js";
|
||||
|
||||
/**
|
||||
* Emit a router for the HTTP operations defined in a given service.
|
||||
*
|
||||
* The generated router will use optimal prefix matching to dispatch requests to the appropriate underlying
|
||||
* implementation using the raw server.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param service - The HTTP service to emit a router for.
|
||||
* @param serverRawModule - The module that contains the raw server implementation.
|
||||
*/
|
||||
export function emitRouter(ctx: HttpContext, service: HttpService, serverRawModule: Module) {
|
||||
const routerModule = createModule("router", ctx.httpModule);
|
||||
|
||||
const routeTree = createRouteTree(ctx, service);
|
||||
|
||||
routerModule.imports.push({
|
||||
binder: "* as http",
|
||||
from: "node:http",
|
||||
});
|
||||
|
||||
routerModule.imports.push({
|
||||
binder: "* as serverRaw",
|
||||
from: serverRawModule,
|
||||
});
|
||||
|
||||
routerModule.declarations.push([...emitRouterDefinition(ctx, service, routeTree, routerModule)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the code for a router of a given service.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param service - The HTTP service to emit a router for.
|
||||
* @param routeTree - The service's route tree.
|
||||
* @param module - The module we're writing to.
|
||||
*/
|
||||
function* emitRouterDefinition(
|
||||
ctx: HttpContext,
|
||||
service: HttpService,
|
||||
routeTree: RouteTree,
|
||||
module: Module
|
||||
): Iterable<string> {
|
||||
const routerName = parseCase(service.namespace.name).pascalCase + "Router";
|
||||
|
||||
const uniqueContainers = new Set(service.operations.map((operation) => operation.container));
|
||||
|
||||
const backends = new Map<OperationContainer, [ReCase, string]>();
|
||||
|
||||
for (const container of uniqueContainers) {
|
||||
const param = parseCase(container.name);
|
||||
|
||||
const traitConstraint =
|
||||
container.kind === "Namespace"
|
||||
? emitNamespaceInterfaceReference(ctx, container, module)
|
||||
: emitTypeReference(ctx, container, container, module);
|
||||
|
||||
module.imports.push({
|
||||
binder: [param.pascalCase],
|
||||
from: createOrGetModuleForNamespace(ctx, container.namespace!),
|
||||
});
|
||||
|
||||
backends.set(container, [param, traitConstraint]);
|
||||
}
|
||||
|
||||
module.imports.push({
|
||||
binder: ["RouterOptions", "createPolicyChain", "createPolicyChainForRoute", "HttpContext"],
|
||||
from: routerHelper,
|
||||
});
|
||||
|
||||
yield `export interface ${routerName} {`;
|
||||
yield ` /**`;
|
||||
yield ` * Dispatches the request to the appropriate service based on the request path.`;
|
||||
yield ` *`;
|
||||
yield ` * This member function may be used directly as a handler for a Node HTTP server.`;
|
||||
yield ` *`;
|
||||
yield ` * @param request - The incoming HTTP request.`;
|
||||
yield ` * @param response - The outgoing HTTP response.`;
|
||||
yield ` */`;
|
||||
yield ` dispatch(request: http.IncomingMessage, response: http.ServerResponse): void;`;
|
||||
|
||||
if (ctx.options.express) {
|
||||
yield "";
|
||||
yield ` /**`;
|
||||
yield ` * An Express middleware function that dispatches the request to the appropriate service based on the request path.`;
|
||||
yield ` *`;
|
||||
yield ` * This member function may be used directly as an application-level middleware function in an Express app.`;
|
||||
yield ` *`;
|
||||
yield ` * If the router does not match a route, it will call the \`next\` middleware registered with the application,`;
|
||||
yield ` * so it is sensible to insert this middleware at the beginning of the middleware stack.`;
|
||||
yield ` *`;
|
||||
yield ` * @param req - The incoming HTTP request.`;
|
||||
yield ` * @param res - The outgoing HTTP response.`;
|
||||
yield ` * @param next - The next middleware function in the stack.`;
|
||||
yield ` */`;
|
||||
yield ` expressMiddleware(req: http.IncomingMessage, res: http.ServerResponse, next: () => void): void;`;
|
||||
}
|
||||
|
||||
yield "}";
|
||||
yield "";
|
||||
|
||||
yield `export function create${routerName}(`;
|
||||
|
||||
for (const [param] of backends.values()) {
|
||||
yield ` ${param.camelCase}: ${param.pascalCase},`;
|
||||
}
|
||||
|
||||
yield ` options: RouterOptions<{`;
|
||||
for (const [param] of backends.values()) {
|
||||
yield ` ${param.camelCase}: ${param.pascalCase}<HttpContext>,`;
|
||||
}
|
||||
yield ` }> = {}`;
|
||||
yield `): ${routerName} {`;
|
||||
|
||||
// Router error case handlers
|
||||
yield ` const onRouteNotFound = options.onRequestNotFound ?? ((request, response) => {`;
|
||||
yield ` response.statusCode = 404;`;
|
||||
yield ` response.setHeader("Content-Type", "text/plain");`;
|
||||
yield ` response.end("Not Found");`;
|
||||
yield ` });`;
|
||||
yield "";
|
||||
yield ` const onInvalidRequest = options.onInvalidRequest ?? ((request, response, route, error) => {`;
|
||||
yield ` response.statusCode = 400;`;
|
||||
yield ` response.setHeader("Content-Type", "application/json");`;
|
||||
yield ` response.end(JSON.stringify({ error }));`;
|
||||
yield ` });`;
|
||||
yield "";
|
||||
yield ` const onInternalError = options.onInternalError ?? ((error, request, response) => {`;
|
||||
yield ` response.statusCode = 500;`;
|
||||
yield ` response.setHeader("Content-Type", "text/plain");`;
|
||||
yield ` response.end("Internal server error.");`;
|
||||
yield ` });`;
|
||||
yield "";
|
||||
yield ` const routePolicies = options.routePolicies ?? {};`;
|
||||
yield "";
|
||||
yield ` const routeHandlers = {`;
|
||||
|
||||
// Policy chains for each operation
|
||||
for (const operation of service.operations) {
|
||||
const operationName = parseCase(operation.operation.name);
|
||||
const containerName = parseCase(operation.container.name);
|
||||
|
||||
yield ` ${containerName.snakeCase}_${operationName.snakeCase}: createPolicyChainForRoute(`;
|
||||
yield ` "${containerName.camelCase + operationName.pascalCase + "Dispatch"}",`;
|
||||
yield ` routePolicies,`;
|
||||
yield ` "${containerName.camelCase}",`;
|
||||
yield ` "${operationName.camelCase}",`;
|
||||
yield ` serverRaw.${containerName.snakeCase}_${operationName.snakeCase},`;
|
||||
yield ` ),`;
|
||||
}
|
||||
|
||||
yield ` } as const;`;
|
||||
yield "";
|
||||
|
||||
// Core routing function definition
|
||||
yield ` const dispatch = createPolicyChain("${routerName}Dispatch", options.policies ?? [], async function(ctx, request, response, onRouteNotFound) {`;
|
||||
yield ` const url = new URL(request.url!, \`http://\${request.headers.host}\`);`;
|
||||
yield ` let path = url.pathname;`;
|
||||
yield "";
|
||||
|
||||
yield* indent(indent(emitRouteHandler(ctx, routeTree, backends, module)));
|
||||
|
||||
yield "";
|
||||
|
||||
yield ` return onRouteNotFound(request, response);`;
|
||||
yield ` });`;
|
||||
yield "";
|
||||
yield ` return {`;
|
||||
yield ` dispatch(request, response) { return dispatch({ request, response }, request, response, onRouteNotFound).catch((e) => onInternalError(e, request, response)); },`;
|
||||
|
||||
if (ctx.options.express) {
|
||||
yield ` expressMiddleware: function (request, response, next) { void dispatch({ request, response }, request, response, function () { next(); }).catch((e) => onInternalError(e, request, response)); },`;
|
||||
}
|
||||
|
||||
yield " }";
|
||||
yield "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes handling code for a single route tree node.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param routeTree - The route tree node to write handling code for.
|
||||
* @param backends - The map of backends for operations.
|
||||
* @param module - The module we're writing to.
|
||||
*/
|
||||
function* emitRouteHandler(
|
||||
ctx: HttpContext,
|
||||
routeTree: RouteTree,
|
||||
backends: Map<OperationContainer, [ReCase, string]>,
|
||||
module: Module
|
||||
): Iterable<string> {
|
||||
const mustTerminate = routeTree.edges.length === 0 && !routeTree.bind;
|
||||
|
||||
yield `if (path.length === 0) {`;
|
||||
if (routeTree.operations.size > 0) {
|
||||
yield* indent(emitRouteOperationDispatch(ctx, routeTree.operations, backends));
|
||||
} else {
|
||||
// Not found
|
||||
yield ` return onRouteNotFound(request, response);`;
|
||||
}
|
||||
yield `}`;
|
||||
|
||||
if (mustTerminate) {
|
||||
// Not found
|
||||
yield "else {";
|
||||
yield ` return onRouteNotFound(request, response);`;
|
||||
yield `}`;
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [edge, nextTree] of routeTree.edges) {
|
||||
const edgePattern = edge.length === 1 ? `'${edge}'` : JSON.stringify(edge);
|
||||
yield `else if (path.startsWith(${edgePattern})) {`;
|
||||
yield ` path = path.slice(${edge.length});`;
|
||||
yield* indent(emitRouteHandler(ctx, nextTree, backends, module));
|
||||
yield "}";
|
||||
}
|
||||
|
||||
if (routeTree.bind) {
|
||||
const [parameterSet, nextTree] = routeTree.bind;
|
||||
const parameters = [...parameterSet];
|
||||
|
||||
yield `else {`;
|
||||
const paramName = parameters.length === 1 ? parameters[0] : "param";
|
||||
yield ` const [${paramName}, rest] = path.split("/", 1);`;
|
||||
yield ` path = rest ?? "";`;
|
||||
if (parameters.length !== 1) {
|
||||
for (const p of parameters) {
|
||||
yield ` const ${parseCase(p).camelCase} = param;`;
|
||||
}
|
||||
}
|
||||
yield* indent(emitRouteHandler(ctx, nextTree, backends, module));
|
||||
|
||||
yield `}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the dispatch code for a specific set of operations mapped to the same route.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param operations - The operations mapped to the route.
|
||||
* @param backends - The map of backends for operations.
|
||||
*/
|
||||
function* emitRouteOperationDispatch(
|
||||
ctx: HttpContext,
|
||||
operations: Map<HttpVerb, RouteOperation[]>,
|
||||
backends: Map<OperationContainer, [ReCase, string]>
|
||||
): Iterable<string> {
|
||||
yield `switch (request.method) {`;
|
||||
for (const [verb, operationList] of operations.entries()) {
|
||||
if (operationList.length === 1) {
|
||||
const operation = operationList[0];
|
||||
const [backend] = backends.get(operation.container)!;
|
||||
const operationName = keywordSafe(
|
||||
backend.snakeCase + "_" + parseCase(operation.operation.name).snakeCase
|
||||
);
|
||||
|
||||
const backendMemberName = backend.camelCase;
|
||||
|
||||
const parameters =
|
||||
operation.parameters.length > 0
|
||||
? ", " + operation.parameters.map((param) => parseCase(param.name).camelCase).join(", ")
|
||||
: "";
|
||||
|
||||
yield ` case ${JSON.stringify(verb.toUpperCase())}:`;
|
||||
yield ` return routeHandlers.${operationName}(ctx, request, response, ${backendMemberName}${parameters});`;
|
||||
} else {
|
||||
// Shared route
|
||||
const route = getHttpOperation(ctx.program, operationList[0].operation)[0].path;
|
||||
yield ` case ${JSON.stringify(verb.toUpperCase())}:`;
|
||||
yield* indent(
|
||||
indent(emitRouteOperationDispatchMultiple(ctx, operationList, route, backends))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
yield ` default:`;
|
||||
yield ` return onRouteNotFound(request, response);`;
|
||||
|
||||
yield "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the dispatch code for a specific set of operations mapped to the same route.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param operations - The operations mapped to the route.
|
||||
* @param backends - The map of backends for operations.
|
||||
*/
|
||||
function* emitRouteOperationDispatchMultiple(
|
||||
ctx: HttpContext,
|
||||
operations: RouteOperation[],
|
||||
route: string,
|
||||
backends: Map<OperationContainer, [ReCase, string]>
|
||||
): Iterable<string> {
|
||||
const usedContentTypes = new Set<string>();
|
||||
const contentTypeMap = new Map<RouteOperation, string>();
|
||||
|
||||
for (const operation of operations) {
|
||||
const [httpOperation] = getHttpOperation(ctx.program, operation.operation);
|
||||
const operationContentType = httpOperation.parameters.parameters.find(
|
||||
(param) => param.type === "header" && param.name.toLowerCase() === "content-type"
|
||||
)?.param.type;
|
||||
|
||||
if (!operationContentType || operationContentType.kind !== "String") {
|
||||
throw new UnimplementedError(
|
||||
"Only string content-types are supported for route differentiation."
|
||||
);
|
||||
}
|
||||
|
||||
if (usedContentTypes.has(operationContentType.value)) {
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "undifferentiable-route",
|
||||
target: httpOperation.operation,
|
||||
});
|
||||
}
|
||||
|
||||
usedContentTypes.add(operationContentType.value);
|
||||
|
||||
contentTypeMap.set(operation, operationContentType.value);
|
||||
}
|
||||
|
||||
yield `const contentType = request.headers["content-type"];`;
|
||||
yield `switch (contentType) {`;
|
||||
|
||||
for (const [operation, contentType] of contentTypeMap.entries()) {
|
||||
const [backend] = backends.get(operation.container)!;
|
||||
const operationName = keywordSafe(
|
||||
backend.snakeCase + "_" + parseCase(operation.operation.name).snakeCase
|
||||
);
|
||||
|
||||
const backendMemberName = backend.camelCase;
|
||||
|
||||
const parameters =
|
||||
operation.parameters.length > 0
|
||||
? ", " + operation.parameters.map((param) => parseCase(param.name).camelCase).join(", ")
|
||||
: "";
|
||||
|
||||
yield ` case ${JSON.stringify(contentType)}:`;
|
||||
yield ` return routeHandlers.${operationName}(ctx, request, response, ${backendMemberName}${parameters});`;
|
||||
}
|
||||
|
||||
yield ` default:`;
|
||||
yield ` return onInvalidRequest(request, response, ${JSON.stringify(route)}, \`No operation in route '${route}' matched content-type "\${contentType}"\`);`;
|
||||
yield "}";
|
||||
}
|
||||
|
||||
/**
|
||||
* A tree of routes in an HTTP router domain.
|
||||
*/
|
||||
interface RouteTree {
|
||||
/**
|
||||
* A list of operations that can be dispatched at this node.
|
||||
*/
|
||||
operations: Map<HttpVerb, RouteOperation[]>;
|
||||
/**
|
||||
* A set of parameters that are bound in this position before proceeding along the subsequent tree.
|
||||
*/
|
||||
bind?: [Set<string>, RouteTree];
|
||||
/**
|
||||
* A list of static edges that can be taken from this node.
|
||||
*/
|
||||
edges: RouteTreeEdge[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An edge in the route tree. The edge contains a literal string prefix that must match before the next node is visited.
|
||||
*/
|
||||
type RouteTreeEdge = readonly [string, RouteTree];
|
||||
|
||||
/**
|
||||
* An operation that may be dispatched at a given tree node.
|
||||
*/
|
||||
interface RouteOperation {
|
||||
/**
|
||||
* The HTTP operation corresponding to this route operation.
|
||||
*/
|
||||
operation: Operation;
|
||||
/**
|
||||
* The operation's container.
|
||||
*/
|
||||
container: OperationContainer;
|
||||
/**
|
||||
* The path parameters that the route template for this operation binds.
|
||||
*/
|
||||
parameters: RouteParameter[];
|
||||
/**
|
||||
* The HTTP verb (GET, PUT, etc.) that this operation requires.
|
||||
*/
|
||||
verb: HttpVerb;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single route split into segments of strings and parameters.
|
||||
*/
|
||||
interface Route extends RouteOperation {
|
||||
segments: RouteSegment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A segment of a single route.
|
||||
*/
|
||||
type RouteSegment = string | RouteParameter;
|
||||
|
||||
/**
|
||||
* A parameter in the route segment with its expected type.
|
||||
*/
|
||||
interface RouteParameter {
|
||||
name: string;
|
||||
type: Type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a route tree for a given service.
|
||||
*/
|
||||
function createRouteTree(ctx: HttpContext, service: HttpService): RouteTree {
|
||||
// First get the Route for each operation in the service.
|
||||
const routes = service.operations.map(function (operation) {
|
||||
const segments = getRouteSegments(ctx, operation);
|
||||
return {
|
||||
operation: operation.operation,
|
||||
container: operation.container,
|
||||
verb: operation.verb,
|
||||
parameters: segments.filter((segment) => typeof segment !== "string"),
|
||||
segments,
|
||||
} as Route;
|
||||
});
|
||||
|
||||
// Build the tree by iteratively removing common prefixes from the text segments.
|
||||
|
||||
const tree = intoRouteTree(routes);
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a route tree from a list of routes.
|
||||
*
|
||||
* This iteratively removes common segments from the routes and then for all routes matching a given common prefix,
|
||||
* builds a nested tree from their subsequent segments.
|
||||
*
|
||||
* @param routes - the routes to build the tree from
|
||||
*/
|
||||
function intoRouteTree(routes: Route[]): RouteTree {
|
||||
const [operations, rest] = bifilter(routes, (route) => route.segments.length === 0);
|
||||
const [literal, parameterized] = bifilter(
|
||||
rest,
|
||||
(route) => typeof route.segments[0]! === "string"
|
||||
);
|
||||
|
||||
const edgeMap = new Map<string, Route[]>();
|
||||
|
||||
// Group the routes by common prefix
|
||||
|
||||
outer: for (const literalRoute of literal) {
|
||||
const segment = literalRoute.segments[0] as string;
|
||||
|
||||
for (const edge of [...edgeMap.keys()]) {
|
||||
const prefix = commonPrefix(segment, edge);
|
||||
|
||||
if (prefix.length > 0) {
|
||||
const existing = edgeMap.get(edge)!;
|
||||
edgeMap.delete(edge);
|
||||
edgeMap.set(prefix, [...existing, literalRoute]);
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
|
||||
edgeMap.set(segment, [literalRoute]);
|
||||
}
|
||||
|
||||
const edges = [...edgeMap.entries()].map(
|
||||
([edge, routes]) =>
|
||||
[
|
||||
edge,
|
||||
intoRouteTree(
|
||||
routes.map(function removePrefix(route) {
|
||||
const [prefix, ...rest] = route.segments as [string, ...RouteSegment[]];
|
||||
|
||||
if (prefix === edge) {
|
||||
return { ...route, segments: rest };
|
||||
} else {
|
||||
return {
|
||||
...route,
|
||||
segments: [prefix.substring(edge.length), ...rest],
|
||||
};
|
||||
}
|
||||
})
|
||||
),
|
||||
] as const
|
||||
);
|
||||
|
||||
let bind: [Set<string>, RouteTree] | undefined;
|
||||
|
||||
if (parameterized.length > 0) {
|
||||
const parameters = new Set<string>();
|
||||
const nextRoutes: Route[] = [];
|
||||
for (const parameterizedRoute of parameterized) {
|
||||
const [{ name }, ...rest] = parameterizedRoute.segments as [
|
||||
RouteParameter,
|
||||
...RouteSegment[],
|
||||
];
|
||||
|
||||
parameters.add(name);
|
||||
nextRoutes.push({ ...parameterizedRoute, segments: rest });
|
||||
}
|
||||
|
||||
bind = [parameters, intoRouteTree(nextRoutes)];
|
||||
}
|
||||
|
||||
const operationMap = new Map<HttpVerb, RouteOperation[]>();
|
||||
|
||||
for (const operation of operations) {
|
||||
let operations = operationMap.get(operation.verb);
|
||||
if (!operations) {
|
||||
operations = [];
|
||||
operationMap.set(operation.verb, operations);
|
||||
}
|
||||
|
||||
operations.push(operation);
|
||||
}
|
||||
|
||||
return {
|
||||
operations: operationMap,
|
||||
bind,
|
||||
edges,
|
||||
};
|
||||
|
||||
function commonPrefix(a: string, b: string): string {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
i++;
|
||||
}
|
||||
return a.substring(0, i);
|
||||
}
|
||||
}
|
||||
|
||||
function getRouteSegments(ctx: HttpContext, operation: HttpOperation): RouteSegment[] {
|
||||
// Parse the route template into segments of "prefixes" (i.e. literal strings)
|
||||
// and parameters (names enclosed in curly braces). The "/" character does not
|
||||
// actually matter for this. We just want to know what the segments of the route
|
||||
// are.
|
||||
//
|
||||
// Examples:
|
||||
// "" => []
|
||||
// "/users" => ["/users"]
|
||||
// "/users/{userId}" => ["/users/", {name: "userId"}]
|
||||
// "/users/{userId}/posts/{postId}" => ["/users/", {name: "userId"}, "/posts/", {name: "postId"}]
|
||||
|
||||
const segments: RouteSegment[] = [];
|
||||
|
||||
const parameterTypeMap = new Map<string, Type>(
|
||||
[...operation.parameters.parameters.values()].map(
|
||||
(p) =>
|
||||
[
|
||||
p.param.name,
|
||||
p.param.type.kind === "ModelProperty" ? p.param.type.type : p.param.type,
|
||||
] as const
|
||||
)
|
||||
);
|
||||
|
||||
let remainingTemplate = operation.path;
|
||||
|
||||
while (remainingTemplate.length > 0) {
|
||||
// Scan for next `{` character
|
||||
const openBraceIndex = remainingTemplate.indexOf("{");
|
||||
|
||||
if (openBraceIndex === -1) {
|
||||
// No more parameters, just add the remaining string as a segment
|
||||
segments.push(remainingTemplate);
|
||||
break;
|
||||
}
|
||||
|
||||
// Add the prefix before the parameter, if there is one
|
||||
if (openBraceIndex > 0) {
|
||||
segments.push(remainingTemplate.substring(0, openBraceIndex));
|
||||
}
|
||||
|
||||
// Scan for next `}` character
|
||||
const closeBraceIndex = remainingTemplate.indexOf("}", openBraceIndex);
|
||||
|
||||
if (closeBraceIndex === -1) {
|
||||
// This is an error in the HTTP layer, so we'll just treat it as if the parameter ends here
|
||||
// and captures the rest of the string as its name.
|
||||
segments.push({
|
||||
name: remainingTemplate.substring(openBraceIndex + 1),
|
||||
type: undefined as any,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract the parameter name
|
||||
const parameterName = remainingTemplate.substring(openBraceIndex + 1, closeBraceIndex);
|
||||
|
||||
segments.push({
|
||||
name: parameterName,
|
||||
type: parameterTypeMap.get(parameterName)!,
|
||||
});
|
||||
|
||||
// Move to the next segment
|
||||
remainingTemplate = remainingTemplate.substring(closeBraceIndex + 1);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { EmitContext, NoTarget, listServices } from "@typespec/compiler";
|
||||
import { visitAllTypes } from "./common/namespace.js";
|
||||
import { JsContext, Module, createModule, createPathCursor } from "./ctx.js";
|
||||
import { JsEmitterOptions, reportDiagnostic } from "./lib.js";
|
||||
import { parseCase } from "./util/case.js";
|
||||
import { UnimplementedError } from "./util/error.js";
|
||||
import { createOnceQueue } from "./util/once-queue.js";
|
||||
import { writeModuleTree } from "./write.js";
|
||||
|
||||
import { createModule as initializeHelperModule } from "../generated-defs/helpers/index.js";
|
||||
|
||||
// #region features
|
||||
|
||||
import { emitSerialization } from "./common/serialization/index.js";
|
||||
import { emitHttp } from "./http/index.js";
|
||||
|
||||
// #endregion
|
||||
|
||||
export { $lib } from "./lib.js";
|
||||
|
||||
export async function $onEmit(context: EmitContext<JsEmitterOptions>) {
|
||||
const services = listServices(context.program);
|
||||
|
||||
if (services.length === 0) {
|
||||
reportDiagnostic(context.program, {
|
||||
code: "no-services-in-program",
|
||||
target: NoTarget,
|
||||
messageId: "default",
|
||||
});
|
||||
return;
|
||||
} else if (services.length > 1) {
|
||||
throw new UnimplementedError("multiple service definitions per program.");
|
||||
}
|
||||
|
||||
const [service] = services;
|
||||
|
||||
const serviceModuleName = parseCase(service.type.name).snakeCase;
|
||||
|
||||
const rootCursor = createPathCursor();
|
||||
|
||||
const globalNamespace = context.program.getGlobalNamespaceType();
|
||||
|
||||
// Root module for emit.
|
||||
const rootModule: Module = {
|
||||
name: serviceModuleName,
|
||||
cursor: rootCursor,
|
||||
|
||||
imports: [],
|
||||
declarations: [],
|
||||
};
|
||||
|
||||
// This has the side effect of setting the `module` property of all helpers.
|
||||
// Don't do anything with the emitter code before this is called.
|
||||
await initializeHelperModule(rootModule);
|
||||
|
||||
// Module for all models, including synthetic and all.
|
||||
const modelsModule: Module = createModule("models", rootModule);
|
||||
|
||||
// Module for all types in all namespaces.
|
||||
const allModule: Module = createModule("all", modelsModule, globalNamespace);
|
||||
|
||||
// Module for all synthetic (named ad-hoc) types.
|
||||
const syntheticModule: Module = createModule("synthetic", modelsModule);
|
||||
|
||||
const jsCtx: JsContext = {
|
||||
program: context.program,
|
||||
options: context.options,
|
||||
globalNamespace,
|
||||
service,
|
||||
|
||||
typeQueue: createOnceQueue(),
|
||||
synthetics: [],
|
||||
syntheticNames: new Map(),
|
||||
|
||||
rootModule,
|
||||
namespaceModules: new Map([[globalNamespace, allModule]]),
|
||||
syntheticModule,
|
||||
modelsModule,
|
||||
globalNamespaceModule: allModule,
|
||||
|
||||
serializations: createOnceQueue(),
|
||||
};
|
||||
|
||||
await emitHttp(jsCtx);
|
||||
|
||||
if (!context.options["omit-unreachable-types"]) {
|
||||
// Visit everything in the service namespace to ensure we emit a full `models` module and not just the subparts that
|
||||
// are reachable from the service impl.
|
||||
|
||||
visitAllTypes(jsCtx, service.type);
|
||||
}
|
||||
|
||||
// Emit serialization code for all required types.
|
||||
emitSerialization(jsCtx);
|
||||
|
||||
if (!context.program.compilerOptions.noEmit) {
|
||||
try {
|
||||
const stat = await context.program.host.stat(context.emitterOutputDir);
|
||||
if (stat.isDirectory()) {
|
||||
await context.program.host.rm(context.emitterOutputDir, {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await writeModuleTree(
|
||||
jsCtx,
|
||||
context.emitterOutputDir,
|
||||
rootModule,
|
||||
!context.options["no-format"]
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { JSONSchemaType, createTypeSpecLibrary, paramMessage } from "@typespec/compiler";
|
||||
|
||||
export interface JsEmitterOptions {
|
||||
express?: boolean;
|
||||
"omit-unreachable-types": boolean;
|
||||
"no-format": boolean;
|
||||
}
|
||||
|
||||
const EmitterOptionsSchema: JSONSchemaType<JsEmitterOptions> = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
express: { type: "boolean", nullable: true, default: false },
|
||||
"omit-unreachable-types": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
"no-format": {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
};
|
||||
|
||||
export const $lib = createTypeSpecLibrary({
|
||||
name: "tsp-js",
|
||||
requireImports: [],
|
||||
emitter: {
|
||||
options: EmitterOptionsSchema,
|
||||
},
|
||||
diagnostics: {
|
||||
"unrecognized-intrinsic": {
|
||||
severity: "warning",
|
||||
messages: {
|
||||
default: paramMessage`unrecognized intrinsic '${"intrinsic"}' is treated as 'unknown'`,
|
||||
},
|
||||
},
|
||||
"unrecognized-scalar": {
|
||||
severity: "warning",
|
||||
messages: {
|
||||
default: paramMessage`unrecognized scalar '${"scalar"}' is treated as 'unknown'`,
|
||||
},
|
||||
},
|
||||
"unrecognized-encoding": {
|
||||
severity: "error",
|
||||
messages: {
|
||||
default: paramMessage`unrecognized encoding '${"encoding"}' for type '${"type"}'`,
|
||||
},
|
||||
},
|
||||
"http-emit-disabled": {
|
||||
severity: "warning",
|
||||
messages: {
|
||||
default: "HTTP emit is disabled because the HTTP library returned errors.",
|
||||
},
|
||||
},
|
||||
"no-services-in-program": {
|
||||
severity: "warning",
|
||||
messages: {
|
||||
default: "No services found in program.",
|
||||
},
|
||||
},
|
||||
"undifferentiable-route": {
|
||||
severity: "error",
|
||||
messages: {
|
||||
default: "Shared route cannot be differentiated from other routes.",
|
||||
},
|
||||
},
|
||||
"undifferentiable-scalar": {
|
||||
severity: "error",
|
||||
messages: {
|
||||
default: paramMessage`Scalar type cannot be differentiated from other scalar type '${"competitor"}'.`,
|
||||
},
|
||||
},
|
||||
"undifferentiable-model": {
|
||||
severity: "error",
|
||||
messages: {
|
||||
default:
|
||||
"Model type does not have enough unique properties to be differentiated from other models in some contexts.",
|
||||
},
|
||||
},
|
||||
"unrepresentable-numeric-constant": {
|
||||
severity: "error",
|
||||
messages: {
|
||||
default: "JavaScript cannot accurately represent this numeric constant.",
|
||||
},
|
||||
},
|
||||
"undifferentiable-union-variant": {
|
||||
severity: "error",
|
||||
messages: {
|
||||
default:
|
||||
"Union variant cannot be differentiated from other variants of the union an an ambiguous context.",
|
||||
},
|
||||
},
|
||||
"name-conflict": {
|
||||
severity: "error",
|
||||
messages: {
|
||||
default: paramMessage`Name ${"name"} conflicts with a prior declaration and must be unique.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { reportDiagnostic } = $lib;
|
||||
|
||||
export { reportDiagnostic };
|
|
@ -0,0 +1,10 @@
|
|||
import {
|
||||
TypeSpecTestLibrary,
|
||||
createTestLibrary,
|
||||
findTestPackageRoot,
|
||||
} from "@typespec/compiler/testing";
|
||||
|
||||
export const HttpServerJavaScriptTestLibrary: TypeSpecTestLibrary = createTestLibrary({
|
||||
name: "@typespec/http-server-javascript",
|
||||
packageRoot: await findTestPackageRoot(import.meta.url),
|
||||
});
|
|
@ -0,0 +1,155 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
/**
|
||||
* Destructures a name into its components.
|
||||
*
|
||||
* The following case conventions are supported:
|
||||
* - PascalCase (["pascal", "case"])
|
||||
* - camelCase (["camel", "case"])
|
||||
* - snake_case (["snake", "case"])
|
||||
* - kebab-case (["kebab", "case"])
|
||||
* - dot.separated (["dot", "separated"])
|
||||
* - path/separated (["path", "separated"])
|
||||
* - double::colon::separated (["double", "colon", "separated"])
|
||||
* - space separated (["space", "separated"])
|
||||
*
|
||||
* - AND any combination of the above, or any other separators or combination of separators.
|
||||
*
|
||||
* @param name - a name in any case
|
||||
*/
|
||||
export function parseCase(name: string): ReCase {
|
||||
const components: string[] = [];
|
||||
|
||||
let currentComponent = "";
|
||||
let inAcronym = false;
|
||||
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
const char = name[i];
|
||||
|
||||
// cSpell:ignore presponse
|
||||
// Special case acronym handling. We want to treat acronyms as a single component,
|
||||
// but we also want the last capitalized letter in an all caps sequence to start a new
|
||||
// component if the next letter is lower case.
|
||||
// For example : "HTTPResponse" => ["http", "response"]
|
||||
// : "OpenAIContext" => ["open", "ai", "context"]
|
||||
// but : "HTTPresponse" (wrong) => ["htt", "presponse"]
|
||||
// however : "HTTP_response" (okay I guess) => ["http", "response"]
|
||||
|
||||
// If the character is a separator or an upper case character, we push the current component and start a new one.
|
||||
if (char === char.toUpperCase() && !/[0-9]/.test(char)) {
|
||||
// If we're in an acronym, we need to check if the next character is lower case.
|
||||
// If it is, then this is the start of a new component.
|
||||
const acronymRestart =
|
||||
inAcronym && /[A-Z]/.test(char) && i + 1 < name.length && /[^A-Z]/.test(name[i + 1]);
|
||||
|
||||
if (currentComponent.length > 0 && (acronymRestart || !inAcronym)) {
|
||||
components.push(currentComponent.trim());
|
||||
currentComponent = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (![":", "_", "-", ".", "/"].includes(char) && !/\s/.test(char)) {
|
||||
currentComponent += char.toLowerCase();
|
||||
}
|
||||
|
||||
inAcronym = /[A-Z]/.test(char);
|
||||
}
|
||||
|
||||
if (currentComponent.length > 0) {
|
||||
components.push(currentComponent);
|
||||
}
|
||||
|
||||
return recase(components);
|
||||
}
|
||||
|
||||
/**
|
||||
* An object allowing a name to be converted into various case conventions.
|
||||
*/
|
||||
export interface ReCase extends ReCaseUpper {
|
||||
/**
|
||||
* The components of the name with the first letter of each component capitalized and joined by an empty string.
|
||||
*/
|
||||
readonly pascalCase: string;
|
||||
/**
|
||||
* The components of the name with the first letter of the second and all subsequent components capitalized and joined
|
||||
* by an empty string.
|
||||
*/
|
||||
readonly camelCase: string;
|
||||
|
||||
/**
|
||||
* Convert the components of the name into all uppercase letters.
|
||||
*/
|
||||
readonly upper: ReCaseUpper;
|
||||
}
|
||||
|
||||
interface ReCaseUpper {
|
||||
/**
|
||||
* The components of the name.
|
||||
*/
|
||||
readonly components: readonly string[];
|
||||
|
||||
/**
|
||||
* The components of the name joined by underscores.
|
||||
*/
|
||||
readonly snakeCase: string;
|
||||
/**
|
||||
* The components of the name joined by hyphens.
|
||||
*/
|
||||
readonly kebabCase: string;
|
||||
/**
|
||||
* The components of the name joined by periods.
|
||||
*/
|
||||
readonly dotCase: string;
|
||||
/**
|
||||
* The components of the name joined by slashes.
|
||||
*
|
||||
* This uses forward slashes in the unix convention.
|
||||
*/
|
||||
readonly pathCase: string;
|
||||
|
||||
/**
|
||||
* Join the components with any given string.
|
||||
*
|
||||
* @param separator - the separator to join the components with
|
||||
*/
|
||||
join(separator: string): string;
|
||||
}
|
||||
|
||||
function recase(components: readonly string[]): ReCase {
|
||||
return Object.freeze({
|
||||
components,
|
||||
get pascalCase() {
|
||||
return components
|
||||
.map((component) => component[0].toUpperCase() + component.slice(1))
|
||||
.join("");
|
||||
},
|
||||
get camelCase() {
|
||||
return components
|
||||
.map((component, index) =>
|
||||
index === 0 ? component : component[0].toUpperCase() + component.slice(1)
|
||||
)
|
||||
.join("");
|
||||
},
|
||||
get snakeCase() {
|
||||
return components.join("_");
|
||||
},
|
||||
get kebabCase() {
|
||||
return components.join("-");
|
||||
},
|
||||
get dotCase() {
|
||||
return components.join(".");
|
||||
},
|
||||
get pathCase() {
|
||||
return components.join("/");
|
||||
},
|
||||
|
||||
get upper() {
|
||||
return recase(components.map((component) => component.toUpperCase()));
|
||||
},
|
||||
|
||||
join(separator: string) {
|
||||
return components.join(separator);
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,895 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import {
|
||||
BooleanLiteral,
|
||||
EnumMember,
|
||||
Model,
|
||||
ModelProperty,
|
||||
NumericLiteral,
|
||||
Scalar,
|
||||
StringLiteral,
|
||||
Type,
|
||||
Union,
|
||||
getDiscriminator,
|
||||
getMaxValue,
|
||||
getMinValue,
|
||||
} from "@typespec/compiler";
|
||||
import { getJsScalar } from "../common/scalar.js";
|
||||
import { JsContext } from "../ctx.js";
|
||||
import { reportDiagnostic } from "../lib.js";
|
||||
import { parseCase } from "./case.js";
|
||||
import { UnimplementedError, UnreachableError } from "./error.js";
|
||||
import { getAllProperties } from "./extends.js";
|
||||
import { categorize, indent } from "./iter.js";
|
||||
|
||||
/**
|
||||
* A tree structure representing a body of TypeScript code.
|
||||
*/
|
||||
export type CodeTree = Result | IfChain | Switch | Verbatim;
|
||||
|
||||
export type JsLiteralType = StringLiteral | BooleanLiteral | NumericLiteral | EnumMember;
|
||||
|
||||
/**
|
||||
* A TypeSpec type that is precise, i.e. the type of a single value.
|
||||
*/
|
||||
export type PreciseType = Scalar | Model | JsLiteralType;
|
||||
|
||||
/**
|
||||
* Determines if `t` is a precise type.
|
||||
* @param t - the type to test
|
||||
* @returns true if `t` is precise, false otherwise.
|
||||
*/
|
||||
export function isPreciseType(t: Type): t is PreciseType {
|
||||
return (
|
||||
t.kind === "Scalar" ||
|
||||
t.kind === "Model" ||
|
||||
t.kind === "Boolean" ||
|
||||
t.kind === "Number" ||
|
||||
t.kind === "String"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An if-chain structure in the CodeTree DSL. This represents a cascading series of if-else-if statements with an optional
|
||||
* final `else` branch.
|
||||
*/
|
||||
export interface IfChain {
|
||||
kind: "if-chain";
|
||||
branches: IfBranch[];
|
||||
else?: CodeTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* A branch in an if-chain.
|
||||
*/
|
||||
export interface IfBranch {
|
||||
/**
|
||||
* A condition to test for this branch.
|
||||
*/
|
||||
condition: Expression;
|
||||
/**
|
||||
* The body of this branch, to be executed if the condition is true.
|
||||
*/
|
||||
body: CodeTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* A node in the code tree indicating that a precise type has been determined.
|
||||
*/
|
||||
export interface Result {
|
||||
kind: "result";
|
||||
type: PreciseType;
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch structure in the CodeTree DSL.
|
||||
*/
|
||||
export interface Switch {
|
||||
kind: "switch";
|
||||
/**
|
||||
* The expression to switch on.
|
||||
*/
|
||||
condition: Expression;
|
||||
/**
|
||||
* The cases to test for.
|
||||
*/
|
||||
cases: SwitchCase[];
|
||||
/**
|
||||
* The default case, if any.
|
||||
*/
|
||||
default?: CodeTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* A verbatim code block.
|
||||
*/
|
||||
export interface Verbatim {
|
||||
kind: "verbatim";
|
||||
body: Iterable<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A case in a switch statement.
|
||||
*/
|
||||
export interface SwitchCase {
|
||||
/**
|
||||
* The value to test for in this case.
|
||||
*/
|
||||
value: Expression;
|
||||
/**
|
||||
* The body of this case.
|
||||
*/
|
||||
body: CodeTree;
|
||||
}
|
||||
|
||||
/**
|
||||
* An expression in the CodeTree DSL.
|
||||
*/
|
||||
export type Expression =
|
||||
| BinaryOp
|
||||
| UnaryOp
|
||||
| TypeOf
|
||||
| Literal
|
||||
| VerbatimExpression
|
||||
| SubjectReference
|
||||
| ModelPropertyReference
|
||||
| InRange;
|
||||
|
||||
/**
|
||||
* A binary operation.
|
||||
*/
|
||||
export interface BinaryOp {
|
||||
kind: "binary-op";
|
||||
/**
|
||||
* The operator to apply. This operation may be sensitive to the order of the left and right expressions.
|
||||
*/
|
||||
operator:
|
||||
| "==="
|
||||
| "!=="
|
||||
| "<"
|
||||
| "<="
|
||||
| ">"
|
||||
| ">="
|
||||
| "+"
|
||||
| "-"
|
||||
| "*"
|
||||
| "/"
|
||||
| "%"
|
||||
| "&&"
|
||||
| "||"
|
||||
| "instanceof"
|
||||
| "in";
|
||||
/**
|
||||
* The left-hand-side operand.
|
||||
*/
|
||||
left: Expression;
|
||||
/**
|
||||
* The right-hand-side operand.
|
||||
*/
|
||||
right: Expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* A unary operation.
|
||||
*/
|
||||
export interface UnaryOp {
|
||||
kind: "unary-op";
|
||||
/**
|
||||
* The operator to apply.
|
||||
*/
|
||||
operator: "!" | "-";
|
||||
/**
|
||||
* The operand to apply the operator to.
|
||||
*/
|
||||
operand: Expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* A type-of operation.
|
||||
*/
|
||||
export interface TypeOf {
|
||||
kind: "typeof";
|
||||
/**
|
||||
* The operand to apply the `typeof` operator to.
|
||||
*/
|
||||
operand: Expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* A literal JavaScript value. The value will be converted to the text of an expression that will yield the same value.
|
||||
*/
|
||||
export interface Literal {
|
||||
kind: "literal";
|
||||
/**
|
||||
* The value of the literal.
|
||||
*/
|
||||
value: LiteralValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* A verbatim expression, written as-is with no modification.
|
||||
*/
|
||||
export interface VerbatimExpression {
|
||||
kind: "verbatim";
|
||||
/**
|
||||
* The exact text of the expression.
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to the "subject" of the code tree.
|
||||
*
|
||||
* The "subject" is a special expression denoting an input value.
|
||||
*/
|
||||
export interface SubjectReference {
|
||||
kind: "subject";
|
||||
}
|
||||
|
||||
/**
|
||||
* A reference to a model property. Model property references are rendered by the `referenceModelProperty` function in the
|
||||
* options given to `writeCodeTree`, allowing the caller to define how model properties are stored.
|
||||
*/
|
||||
export interface ModelPropertyReference {
|
||||
kind: "model-property";
|
||||
property: ModelProperty;
|
||||
}
|
||||
|
||||
/**
|
||||
* A check to see if a value is in an integer range.
|
||||
*/
|
||||
export interface InRange {
|
||||
kind: "in-range";
|
||||
/**
|
||||
* The expression to check.
|
||||
*/
|
||||
expr: Expression;
|
||||
/**
|
||||
* The range to check against.
|
||||
*/
|
||||
range: IntegerRange;
|
||||
}
|
||||
|
||||
/**
|
||||
* A literal value that can be used in a JavaScript expression.
|
||||
*/
|
||||
export type LiteralValue = string | number | boolean | bigint;
|
||||
|
||||
function isLiteralValueType(type: Type): type is JsLiteralType {
|
||||
return (
|
||||
type.kind === "Boolean" ||
|
||||
type.kind === "Number" ||
|
||||
type.kind === "String" ||
|
||||
type.kind === "EnumMember"
|
||||
);
|
||||
}
|
||||
|
||||
const PROPERTY_ID = (prop: ModelProperty) => parseCase(prop.name).camelCase;
|
||||
|
||||
/**
|
||||
* Differentiates the variants of a union type. This function returns a CodeTree that will test an input "subject" and
|
||||
* determine which of the cases it matches.
|
||||
*
|
||||
* Compared to `differentiateTypes`, this function is specialized for union types, and will consider union
|
||||
* discriminators first, then delegate to `differentiateTypes` for the remaining cases.
|
||||
*
|
||||
* @param ctx
|
||||
* @param type
|
||||
*/
|
||||
export function differentiateUnion(
|
||||
ctx: JsContext,
|
||||
union: Union,
|
||||
renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID
|
||||
): CodeTree {
|
||||
const discriminator = getDiscriminator(ctx.program, union)?.propertyName;
|
||||
const variants = [...union.variants.values()];
|
||||
|
||||
if (!discriminator) {
|
||||
const cases = new Set<PreciseType>();
|
||||
|
||||
for (const variant of variants) {
|
||||
if (!isPreciseType(variant.type)) {
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "undifferentiable-union-variant",
|
||||
target: variant,
|
||||
});
|
||||
} else {
|
||||
cases.add(variant.type);
|
||||
}
|
||||
}
|
||||
|
||||
return differentiateTypes(ctx, cases, PROPERTY_ID);
|
||||
} else {
|
||||
const property = (variants[0].type as Model).properties.get(discriminator)!;
|
||||
|
||||
return {
|
||||
kind: "switch",
|
||||
condition: {
|
||||
kind: "model-property",
|
||||
property,
|
||||
},
|
||||
cases: variants.map((v) => {
|
||||
const discriminatorPropertyType = (v.type as Model).properties.get(discriminator)!.type as
|
||||
| JsLiteralType
|
||||
| EnumMember;
|
||||
|
||||
return {
|
||||
value: { kind: "literal", value: getJsValue(ctx, discriminatorPropertyType) },
|
||||
body: { kind: "result", type: v.type },
|
||||
} as SwitchCase;
|
||||
}),
|
||||
default: {
|
||||
kind: "verbatim",
|
||||
body: [
|
||||
'throw new Error("Unreachable: discriminator did not match any known value or was not present.");',
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Differentiates a set of input types. This function returns a CodeTree that will test an input "subject" and determine
|
||||
* which of the cases it matches, executing the corresponding code block.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param cases - A map of cases to differentiate to their respective code blocks.
|
||||
* @returns a CodeTree to use with `writeCodeTree`
|
||||
*/
|
||||
export function differentiateTypes(
|
||||
ctx: JsContext,
|
||||
cases: Set<PreciseType>,
|
||||
renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID
|
||||
): CodeTree {
|
||||
if (cases.size === 0) {
|
||||
return {
|
||||
kind: "verbatim",
|
||||
body: [
|
||||
'throw new Error("Unreachable: encountered a value in differentiation where no variants exist.");',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const categories = categorize(cases.keys(), (type) => type.kind);
|
||||
|
||||
const literals = [
|
||||
...(categories.Boolean ?? []),
|
||||
...(categories.Number ?? []),
|
||||
...(categories.String ?? []),
|
||||
] as JsLiteralType[];
|
||||
const models = (categories.Model as Model[]) ?? [];
|
||||
const scalars = (categories.Scalar as Scalar[]) ?? [];
|
||||
|
||||
if (literals.length + scalars.length === 0) {
|
||||
return differentiateModelTypes(ctx, select(models, cases));
|
||||
} else {
|
||||
const branches: IfBranch[] = [];
|
||||
for (const literal of literals) {
|
||||
branches.push({
|
||||
condition: {
|
||||
kind: "binary-op",
|
||||
operator: "===",
|
||||
left: { kind: "subject" },
|
||||
right: { kind: "literal", value: getJsValue(ctx, literal) },
|
||||
},
|
||||
body: {
|
||||
kind: "result",
|
||||
type: literal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const scalarRepresentations = new Map<string, Scalar>();
|
||||
|
||||
for (const scalar of scalars) {
|
||||
const jsScalar = getJsScalar(ctx.program, scalar, scalar);
|
||||
|
||||
if (scalarRepresentations.has(jsScalar)) {
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "undifferentiable-scalar",
|
||||
target: scalar,
|
||||
format: {
|
||||
competitor: scalarRepresentations.get(jsScalar)!.name,
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let test: Expression;
|
||||
|
||||
switch (jsScalar) {
|
||||
case "Uint8Array":
|
||||
test = {
|
||||
kind: "binary-op",
|
||||
operator: "instanceof",
|
||||
left: { kind: "subject" },
|
||||
right: { kind: "verbatim", text: "Uint8Array" },
|
||||
};
|
||||
break;
|
||||
case "number":
|
||||
test = {
|
||||
kind: "binary-op",
|
||||
operator: "===",
|
||||
left: { kind: "typeof", operand: { kind: "subject" } },
|
||||
right: { kind: "literal", value: "number" },
|
||||
};
|
||||
break;
|
||||
case "bigint":
|
||||
test = {
|
||||
kind: "binary-op",
|
||||
operator: "===",
|
||||
left: { kind: "typeof", operand: { kind: "subject" } },
|
||||
right: { kind: "literal", value: "bigint" },
|
||||
};
|
||||
break;
|
||||
case "string":
|
||||
test = {
|
||||
kind: "binary-op",
|
||||
operator: "===",
|
||||
left: { kind: "typeof", operand: { kind: "subject" } },
|
||||
right: { kind: "literal", value: "string" },
|
||||
};
|
||||
break;
|
||||
case "boolean":
|
||||
test = {
|
||||
kind: "binary-op",
|
||||
operator: "===",
|
||||
left: { kind: "typeof", operand: { kind: "subject" } },
|
||||
right: { kind: "literal", value: "boolean" },
|
||||
};
|
||||
break;
|
||||
case "Date":
|
||||
test = {
|
||||
kind: "binary-op",
|
||||
operator: "instanceof",
|
||||
left: { kind: "subject" },
|
||||
right: { kind: "verbatim", text: "Date" },
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new UnimplementedError(
|
||||
`scalar differentiation for unknown JS Scalar '${jsScalar}'.`
|
||||
);
|
||||
}
|
||||
|
||||
branches.push({
|
||||
condition: test,
|
||||
body: {
|
||||
kind: "result",
|
||||
type: scalar,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "if-chain",
|
||||
branches,
|
||||
else: models.length > 0 ? differentiateModelTypes(ctx, select(models, cases)) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a subset of keys from a map.
|
||||
*
|
||||
* @param keys - The keys to select.
|
||||
* @param map - The map to select from.
|
||||
* @returns a map containing only those keys of the original map that were also in the `keys` iterable.
|
||||
*/
|
||||
function select<V1, V2 extends V1>(keys: Iterable<V2>, set: Set<V1>): Set<V2> {
|
||||
const result = new Set<V2>();
|
||||
for (const key of keys) {
|
||||
if (set.has(key)) result.add(key);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a JavaScript literal value for a given LiteralType.
|
||||
*/
|
||||
function getJsValue(ctx: JsContext, literal: JsLiteralType | EnumMember): LiteralValue {
|
||||
switch (literal.kind) {
|
||||
case "Boolean":
|
||||
return literal.value;
|
||||
case "Number": {
|
||||
const asNumber = literal.numericValue.asNumber();
|
||||
|
||||
if (asNumber) return asNumber;
|
||||
|
||||
const asBigInt = literal.numericValue.asBigInt();
|
||||
|
||||
if (asBigInt) return asBigInt;
|
||||
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "unrepresentable-numeric-constant",
|
||||
target: literal,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
case "String":
|
||||
return literal.value;
|
||||
case "EnumMember":
|
||||
return literal.value ?? literal.name;
|
||||
default:
|
||||
throw new UnreachableError(
|
||||
"getJsValue for " + (literal satisfies never as JsLiteralType).kind,
|
||||
{ literal }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An integer range, inclusive.
|
||||
*/
|
||||
type IntegerRange = [number, number];
|
||||
|
||||
function getIntegerRange(ctx: JsContext, property: ModelProperty): IntegerRange | false {
|
||||
if (
|
||||
property.type.kind === "Scalar" &&
|
||||
getJsScalar(ctx.program, property.type, property) === "number"
|
||||
) {
|
||||
const minValue = getMinValue(ctx.program, property);
|
||||
const maxValue = getMaxValue(ctx.program, property);
|
||||
|
||||
if (minValue !== undefined && maxValue !== undefined) {
|
||||
return [minValue, maxValue];
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function overlaps(range: IntegerRange, other: IntegerRange): boolean {
|
||||
return range[0] <= other[1] && range[1] >= other[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Differentiate a set of model types based on their properties. This function returns a CodeTree that will test an input
|
||||
* "subject" and determine which of the cases it matches, executing the corresponding code block.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param models - A map of models to differentiate to their respective code blocks.
|
||||
* @param renderPropertyName - A function that converts a model property reference over the subject to a string.
|
||||
* @returns a CodeTree to use with `writeCodeTree`
|
||||
*/
|
||||
export function differentiateModelTypes(
|
||||
ctx: JsContext,
|
||||
models: Set<Model>,
|
||||
renderPropertyName: (prop: ModelProperty) => string = PROPERTY_ID
|
||||
): CodeTree {
|
||||
// Horrible n^2 operation to get the unique properties of all models in the map, but hopefully n is small, so it should
|
||||
// be okay until you have a lot of models to differentiate.
|
||||
|
||||
type PropertyName = string;
|
||||
type RenderedPropertyName = string & { __brand: "RenderedPropertyName" };
|
||||
|
||||
const uniqueProps = new Map<Model, Set<PropertyName>>();
|
||||
|
||||
// Map of property names to maps of literal values that identify a model.
|
||||
const propertyLiterals = new Map<RenderedPropertyName, Map<LiteralValue, Model>>();
|
||||
// Map of models to properties with values that can uniquely identify it
|
||||
const uniqueLiterals = new Map<Model, Set<RenderedPropertyName>>();
|
||||
|
||||
const propertyRanges = new Map<RenderedPropertyName, Map<IntegerRange, Model>>();
|
||||
const uniqueRanges = new Map<Model, Set<RenderedPropertyName>>();
|
||||
|
||||
for (const model of models) {
|
||||
const props = new Set<string>();
|
||||
|
||||
for (const prop of getAllProperties(model)) {
|
||||
// Don't consider optional properties for differentiation.
|
||||
if (prop.optional) continue;
|
||||
|
||||
const renderedPropName = renderPropertyName(prop) as RenderedPropertyName;
|
||||
|
||||
// CASE - literal value
|
||||
|
||||
if (isLiteralValueType(prop.type)) {
|
||||
let literals = propertyLiterals.get(renderedPropName);
|
||||
if (!literals) {
|
||||
literals = new Map();
|
||||
propertyLiterals.set(renderedPropName, literals);
|
||||
}
|
||||
|
||||
const value = getJsValue(ctx, prop.type);
|
||||
|
||||
const other = literals.get(value);
|
||||
|
||||
if (other) {
|
||||
// Literal already used. Leave the literal in the propertyLiterals map to prevent future collisions,
|
||||
// but remove the model from the uniqueLiterals map.
|
||||
uniqueLiterals.get(other)?.delete(renderedPropName);
|
||||
} else {
|
||||
// Literal is available. Add the model to the uniqueLiterals map and set this value.
|
||||
literals.set(value, model);
|
||||
let modelsUniqueLiterals = uniqueLiterals.get(model);
|
||||
if (!modelsUniqueLiterals) {
|
||||
modelsUniqueLiterals = new Set();
|
||||
uniqueLiterals.set(model, modelsUniqueLiterals);
|
||||
}
|
||||
modelsUniqueLiterals.add(renderedPropName);
|
||||
}
|
||||
}
|
||||
|
||||
// CASE - unique range
|
||||
|
||||
const range = getIntegerRange(ctx, prop);
|
||||
if (range) {
|
||||
let ranges = propertyRanges.get(renderedPropName);
|
||||
if (!ranges) {
|
||||
ranges = new Map();
|
||||
propertyRanges.set(renderedPropName, ranges);
|
||||
}
|
||||
|
||||
const overlappingRanges = [...ranges.entries()].filter(([r]) => overlaps(r, range));
|
||||
|
||||
if (overlappingRanges.length > 0) {
|
||||
// Overlapping range found. Remove the model from the uniqueRanges map.
|
||||
for (const [, other] of overlappingRanges) {
|
||||
uniqueRanges.get(other)?.delete(renderedPropName);
|
||||
}
|
||||
} else {
|
||||
// No overlapping range found. Add the model to the uniqueRanges map and set this range.
|
||||
ranges.set(range, model);
|
||||
let modelsUniqueRanges = uniqueRanges.get(model);
|
||||
if (!modelsUniqueRanges) {
|
||||
modelsUniqueRanges = new Set();
|
||||
uniqueRanges.set(model, modelsUniqueRanges);
|
||||
}
|
||||
modelsUniqueRanges.add(renderedPropName);
|
||||
}
|
||||
}
|
||||
|
||||
// CASE - unique property
|
||||
|
||||
let valid = true;
|
||||
for (const [, other] of uniqueProps) {
|
||||
if (
|
||||
other.has(prop.name) ||
|
||||
(isLiteralValueType(prop.type) &&
|
||||
propertyLiterals
|
||||
.get(renderedPropName)
|
||||
?.has(getJsValue(ctx, prop.type as JsLiteralType)))
|
||||
) {
|
||||
valid = false;
|
||||
other.delete(prop.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
props.add(prop.name);
|
||||
}
|
||||
}
|
||||
|
||||
uniqueProps.set(model, props);
|
||||
}
|
||||
|
||||
const branches: IfBranch[] = [];
|
||||
|
||||
let defaultCase: Model | undefined = undefined;
|
||||
|
||||
for (const [model, unique] of uniqueProps) {
|
||||
const literals = uniqueLiterals.get(model);
|
||||
const ranges = uniqueRanges.get(model);
|
||||
if (unique.size === 0 && (!literals || literals.size === 0) && (!ranges || ranges.size === 0)) {
|
||||
if (defaultCase) {
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "undifferentiable-model",
|
||||
target: model,
|
||||
});
|
||||
return {
|
||||
kind: "result",
|
||||
type: defaultCase,
|
||||
};
|
||||
} else {
|
||||
// Allow a single default case. This covers more APIs that have a single model that is not differentiated by a
|
||||
// unique property, in which case we can make it the `else` case.
|
||||
defaultCase = model;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (literals && literals.size > 0) {
|
||||
// A literal property value exists that can differentiate this model.
|
||||
const firstUniqueLiteral = literals.values().next().value as RenderedPropertyName;
|
||||
|
||||
const property = [...model.properties.values()].find(
|
||||
(p) => (renderPropertyName(p) as RenderedPropertyName) === firstUniqueLiteral
|
||||
)!;
|
||||
|
||||
branches.push({
|
||||
condition: {
|
||||
kind: "binary-op",
|
||||
left: { kind: "model-property", property },
|
||||
operator: "===",
|
||||
right: {
|
||||
kind: "literal",
|
||||
value: getJsValue(ctx, property.type as JsLiteralType),
|
||||
},
|
||||
},
|
||||
body: { kind: "result", type: model },
|
||||
});
|
||||
} else if (ranges && ranges.size > 0) {
|
||||
// A range property value exists that can differentiate this model.
|
||||
const firstUniqueRange = ranges.values().next().value as RenderedPropertyName;
|
||||
|
||||
const property = [...model.properties.values()].find(
|
||||
(p) => renderPropertyName(p) === firstUniqueRange
|
||||
)!;
|
||||
|
||||
const range = [...propertyRanges.get(firstUniqueRange)!.entries()].find(
|
||||
([range, candidate]) => candidate === model
|
||||
)![0];
|
||||
|
||||
branches.push({
|
||||
condition: {
|
||||
kind: "in-range",
|
||||
expr: { kind: "model-property", property },
|
||||
range,
|
||||
},
|
||||
body: { kind: "result", type: model },
|
||||
});
|
||||
} else {
|
||||
const firstUniqueProp = unique.values().next().value as PropertyName;
|
||||
|
||||
branches.push({
|
||||
condition: {
|
||||
kind: "binary-op",
|
||||
left: { kind: "literal", value: firstUniqueProp },
|
||||
operator: "in",
|
||||
right: { kind: "subject" },
|
||||
},
|
||||
body: { kind: "result", type: model },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "if-chain",
|
||||
branches,
|
||||
else: defaultCase
|
||||
? {
|
||||
kind: "result",
|
||||
type: defaultCase,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the `writeCodeTree` function.
|
||||
*/
|
||||
export interface CodeTreeOptions {
|
||||
/**
|
||||
* The subject expression to use in the code tree.
|
||||
*
|
||||
* This text is used whenever a `SubjectReference` is encountered in the code tree, allowing the caller to specify
|
||||
* how the subject is stored and referenced.
|
||||
*/
|
||||
subject: string;
|
||||
|
||||
/**
|
||||
* A function that converts a model property to a string reference.
|
||||
*
|
||||
* This function is used whenever a `ModelPropertyReference` is encountered in the code tree, allowing the caller to
|
||||
* specify how model properties are stored and referenced.
|
||||
*/
|
||||
referenceModelProperty: (p: ModelProperty) => string;
|
||||
|
||||
/**
|
||||
* Renders a result when encountered in the code tree.
|
||||
*/
|
||||
renderResult: (type: Type) => Iterable<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a code tree to text, given a set of options.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param tree - The code tree to write.
|
||||
* @param options - The options to use when writing the code tree.
|
||||
*/
|
||||
export function* writeCodeTree(
|
||||
ctx: JsContext,
|
||||
tree: CodeTree,
|
||||
options: CodeTreeOptions
|
||||
): Iterable<string> {
|
||||
switch (tree.kind) {
|
||||
case "result":
|
||||
yield* options.renderResult(tree.type);
|
||||
break;
|
||||
case "if-chain": {
|
||||
let first = true;
|
||||
for (const branch of tree.branches) {
|
||||
const condition = writeExpression(ctx, branch.condition, options);
|
||||
if (first) {
|
||||
first = false;
|
||||
yield `if (${condition}) {`;
|
||||
} else {
|
||||
yield `} else if (${condition}) {`;
|
||||
}
|
||||
yield* indent(writeCodeTree(ctx, branch.body, options));
|
||||
}
|
||||
if (tree.else) {
|
||||
yield "} else {";
|
||||
yield* indent(writeCodeTree(ctx, tree.else, options));
|
||||
}
|
||||
yield "}";
|
||||
break;
|
||||
}
|
||||
case "switch": {
|
||||
yield `switch (${writeExpression(ctx, tree.condition, options)}) {`;
|
||||
for (const _case of tree.cases) {
|
||||
yield ` case ${writeExpression(ctx, _case.value, options)}: {`;
|
||||
yield* indent(indent(writeCodeTree(ctx, _case.body, options)));
|
||||
yield " }";
|
||||
}
|
||||
if (tree.default) {
|
||||
yield " default: {";
|
||||
yield* indent(indent(writeCodeTree(ctx, tree.default, options)));
|
||||
yield " }";
|
||||
}
|
||||
yield "}";
|
||||
break;
|
||||
}
|
||||
case "verbatim":
|
||||
yield* tree.body;
|
||||
break;
|
||||
default:
|
||||
throw new UnreachableError("writeCodeTree for " + (tree satisfies never as CodeTree).kind, {
|
||||
tree,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function writeExpression(ctx: JsContext, expression: Expression, options: CodeTreeOptions): string {
|
||||
switch (expression.kind) {
|
||||
case "binary-op":
|
||||
return `(${writeExpression(ctx, expression.left, options)}) ${expression.operator} (${writeExpression(
|
||||
ctx,
|
||||
expression.right,
|
||||
options
|
||||
)})`;
|
||||
case "unary-op":
|
||||
return `${expression.operator}(${writeExpression(ctx, expression.operand, options)})`;
|
||||
case "typeof":
|
||||
return `typeof (${writeExpression(ctx, expression.operand, options)})`;
|
||||
case "literal":
|
||||
switch (typeof expression.value) {
|
||||
case "string":
|
||||
return JSON.stringify(expression.value);
|
||||
case "number":
|
||||
case "bigint":
|
||||
return String(expression.value);
|
||||
case "boolean":
|
||||
return expression.value ? "true" : "false";
|
||||
default:
|
||||
throw new UnreachableError(
|
||||
`writeExpression for literal value type '${typeof expression.value}'`
|
||||
);
|
||||
}
|
||||
case "in-range": {
|
||||
const {
|
||||
expr,
|
||||
range: [min, max],
|
||||
} = expression;
|
||||
const exprText = writeExpression(ctx, expr, options);
|
||||
|
||||
return `(${exprText} >= ${min} && ${exprText} <= ${max})`;
|
||||
}
|
||||
case "verbatim":
|
||||
return expression.text;
|
||||
case "subject":
|
||||
return options.subject;
|
||||
case "model-property":
|
||||
return options.referenceModelProperty(expression.property);
|
||||
default:
|
||||
throw new UnreachableError(
|
||||
"writeExpression for " + (expression satisfies never as Expression).kind,
|
||||
{
|
||||
expression,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
/**
|
||||
* A utility error for unimplemented functionality.
|
||||
*/
|
||||
export class UnimplementedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`Unimplemented: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility error for unreachable code paths.
|
||||
*/
|
||||
export class UnreachableError extends Error {
|
||||
constructor(message: string, values?: Record<string, never>) {
|
||||
let fullMessage = `Unreachable: ${message}`;
|
||||
|
||||
if (values) {
|
||||
fullMessage += `\nObserved values: ${Object.entries(values)
|
||||
.map(([k, v]) => ` ${k}: ${String(v)}`)
|
||||
.join(",\n")}`;
|
||||
}
|
||||
|
||||
super(fullMessage);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Interface, Model, ModelProperty, Operation } from "@typespec/compiler";
|
||||
|
||||
/**
|
||||
* Recursively collects all properties of a model, including inherited properties.
|
||||
*/
|
||||
export function getAllProperties(model: Model, visited: Set<Model> = new Set()): ModelProperty[] {
|
||||
if (visited.has(model)) return [];
|
||||
|
||||
visited.add(model);
|
||||
|
||||
const properties = [...model.properties.values()];
|
||||
|
||||
if (model.baseModel) {
|
||||
properties.push(...getAllProperties(model.baseModel, visited));
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collects all operations in an interface, including those inherited from source interfaces.
|
||||
*/
|
||||
export function getAllOperations(
|
||||
iface: Interface,
|
||||
visited: Set<Interface> = new Set()
|
||||
): Operation[] {
|
||||
if (visited.has(iface)) return [];
|
||||
|
||||
visited.add(iface);
|
||||
|
||||
const operations = [...iface.operations.values()];
|
||||
|
||||
if (iface.sourceInterfaces) {
|
||||
for (const source of iface.sourceInterfaces) {
|
||||
operations.push(...getAllOperations(source, visited));
|
||||
}
|
||||
}
|
||||
|
||||
return operations;
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
/**
|
||||
* Returns true if a value implements the ECMAScript Iterable interface.
|
||||
*/
|
||||
export function isIterable(value: unknown): value is object & Iterable<unknown> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Symbol.iterator in value &&
|
||||
typeof (value as Iterable<unknown>)[Symbol.iterator] === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate multiple iterables into a single iterable.
|
||||
*/
|
||||
export function* cat<T>(...iterables: Iterable<T>[]): Iterable<T> {
|
||||
for (const iterable of iterables) {
|
||||
yield* iterable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and collect an iterable into multiple groups based on a categorization function.
|
||||
*
|
||||
* The categorization function returns a string key for each value, and the values are returned in an object where each
|
||||
* key is a category returned by the categorization function and the value is an array of values in that category.
|
||||
*
|
||||
* @param values - an iterable of values to categorize
|
||||
* @param categorize - a categorization function that returns a string key for each value
|
||||
* @returns an object where each key is a category and the value is an array of values in that category
|
||||
*/
|
||||
|
||||
export function categorize<T, K extends string>(
|
||||
values: Iterable<T>,
|
||||
categorize: (o: T) => K
|
||||
): Partial<Record<K, T[]>> {
|
||||
const result: Record<K, T[]> = {} as any;
|
||||
|
||||
for (const value of values) {
|
||||
(result[categorize(value)] ??= []).push(value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and collect an iterable into two categorizations based on a predicate function.
|
||||
*
|
||||
* Items for which the predicate returns true will be returned in the first array.
|
||||
* Items for which the predicate returns false will be returned in the second array.
|
||||
*
|
||||
* @param values - an iterable of values to filter
|
||||
* @param predicate - a predicate function that decides whether a value should be included in the first or second array
|
||||
*
|
||||
* @returns a tuple of two arrays of values filtered by the predicate
|
||||
*/
|
||||
export function bifilter<T>(values: Iterable<T>, predicate: (o: T) => boolean): [T[], T[]] {
|
||||
const pass: T[] = [];
|
||||
const fail: T[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
if (predicate(value)) {
|
||||
pass.push(value);
|
||||
} else {
|
||||
fail.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
return [pass, fail];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends a string `indentation` to each value in `values`.
|
||||
*
|
||||
* @param values - an iterable of strings to indent
|
||||
* @param indentation - the string to prepend to the beginning of each value
|
||||
*/
|
||||
export function* indent(values: Iterable<string>, indentation: string = " "): Iterable<string> {
|
||||
for (const value of values) {
|
||||
yield indentation + value;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
const KEYWORDS_CONTEXTUAL = [
|
||||
"any",
|
||||
"boolean",
|
||||
"constructor",
|
||||
"declare",
|
||||
"get",
|
||||
"module",
|
||||
"require",
|
||||
"number",
|
||||
"set",
|
||||
"string",
|
||||
];
|
||||
|
||||
const KEYWORDS_STRICT = [
|
||||
"as",
|
||||
"implements",
|
||||
"interface",
|
||||
"let",
|
||||
"package",
|
||||
"private",
|
||||
"protected",
|
||||
"public",
|
||||
"static",
|
||||
"yield",
|
||||
"symbol",
|
||||
"type",
|
||||
"from",
|
||||
"of",
|
||||
];
|
||||
|
||||
const KEYWORDS_RESERVED = [
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"enum",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"finally",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"new",
|
||||
"null",
|
||||
"return",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"typeof",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"with",
|
||||
|
||||
"namespace",
|
||||
"async",
|
||||
"await",
|
||||
"module",
|
||||
"delete",
|
||||
];
|
||||
|
||||
/**
|
||||
* A set of reserved keywords that should not be used as identifiers.
|
||||
*/
|
||||
export const KEYWORDS = new Set([...KEYWORDS_STRICT, ...KEYWORDS_RESERVED, ...KEYWORDS_CONTEXTUAL]);
|
||||
|
||||
/**
|
||||
* Makes a name safe to use as an identifier by prefixing it with an underscore
|
||||
* if it would conflict with a keyword.
|
||||
*/
|
||||
export function keywordSafe(name: string): string {
|
||||
return KEYWORDS.has(name) ? `_${name}` : name;
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { Namespace, Type } from "@typespec/compiler";
|
||||
|
||||
/**
|
||||
* A TypeSpec type that may be attached to a namespace.
|
||||
*/
|
||||
export type NamespacedType = Extract<Type, { namespace?: Namespace | undefined }>;
|
||||
|
||||
/**
|
||||
* Computes the fully-qualified name of a TypeSpec type, i.e. `TypeSpec.boolean` for the built-in `boolean` scalar.
|
||||
*/
|
||||
export function getFullyQualifiedTypeName(type: NamespacedType): string {
|
||||
const name = type.name ?? "<unknown>";
|
||||
if (type.namespace) {
|
||||
return getFullyQualifiedNamespacePath(type.namespace).join(".") + "." + name;
|
||||
} else {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
function getFullyQualifiedNamespacePath(ns: Namespace): string[] {
|
||||
if (ns.namespace) {
|
||||
const innerPath = getFullyQualifiedNamespacePath(ns.namespace);
|
||||
innerPath.push(ns.name);
|
||||
return innerPath;
|
||||
} else {
|
||||
return [ns.name];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
/**
|
||||
* A deduplicating queue that only allows elements to be enqueued once.
|
||||
*
|
||||
* This uses a Set to track visited elements.
|
||||
*/
|
||||
export interface OnceQueue<T> {
|
||||
/**
|
||||
* Enqueue a value if it has not been enqueued before.
|
||||
*/
|
||||
add(value: T): void;
|
||||
/**
|
||||
* Dequeue the next value.
|
||||
*/
|
||||
take(): T | undefined;
|
||||
/**
|
||||
* Check if the queue is empty.
|
||||
*/
|
||||
isEmpty(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new OnceQueue with the given initial values.
|
||||
*/
|
||||
export function createOnceQueue<T>(...initialValues: T[]): OnceQueue<T> {
|
||||
const visited = new Set<T>();
|
||||
const queue = [] as T[];
|
||||
let idx = 0;
|
||||
const oncequeue: OnceQueue<T> = {
|
||||
add(value: T): void {
|
||||
if (!visited.has(value)) {
|
||||
visited.add(value);
|
||||
queue.push(value);
|
||||
}
|
||||
},
|
||||
take(): T | undefined {
|
||||
if (idx < queue.length) {
|
||||
return queue[idx++];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
isEmpty(): boolean {
|
||||
return idx >= queue.length;
|
||||
},
|
||||
};
|
||||
|
||||
for (const value of initialValues) {
|
||||
oncequeue.add(value);
|
||||
}
|
||||
|
||||
return oncequeue;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
/**
|
||||
* Provides an alternative name for anonymous TypeSpec.Array elements.
|
||||
* @param typeName
|
||||
* @returns
|
||||
*/
|
||||
export function getArrayElementName(typeName: string): string {
|
||||
return typeName + "Element";
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides an alternative name for anonymous TypeSpec.Record values.
|
||||
* @param typeName
|
||||
* @returns
|
||||
*/
|
||||
export function getRecordValueName(typeName: string): string {
|
||||
return typeName + "Value";
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces the name of an array type for a given base type.
|
||||
*
|
||||
* If the type name is a simple identifier, this will use the `[]` syntax,
|
||||
* otherwise it will use the `Array<>` type constructor.
|
||||
*
|
||||
* @param typeName - the base type to make an array of
|
||||
* @returns a good representation of an array of the base type
|
||||
*/
|
||||
export function asArrayType(typeName: string): string {
|
||||
if (/^[a-zA-Z_]+$/.test(typeName)) {
|
||||
return typeName + "[]";
|
||||
} else {
|
||||
return `Array<${typeName}>`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,211 @@
|
|||
import { DiagnosticTarget, NoTarget } from "@typespec/compiler";
|
||||
import { JsContext } from "../ctx.js";
|
||||
import { reportDiagnostic } from "../lib.js";
|
||||
import { UnreachableError } from "./error.js";
|
||||
|
||||
/**
|
||||
* A conceptual lexical scope.
|
||||
*/
|
||||
export interface Scope {
|
||||
/**
|
||||
* Declare a name in the scope, applying the appropriate resolution strategy if necessary.
|
||||
*
|
||||
* @param primaryName - the primary name we want to declare in this scope
|
||||
* @param options - options for the declaration
|
||||
* @returns the name that was finally declared in the scope
|
||||
*/
|
||||
declare(primaryName: string, options?: DeclarationOptions): string;
|
||||
|
||||
/**
|
||||
* Determines whether or not a given name is declared in the scope.
|
||||
*
|
||||
* @param name - the name to check for declaration
|
||||
*/
|
||||
isDeclared(name: string): boolean;
|
||||
}
|
||||
|
||||
export interface DeclarationOptions {
|
||||
/**
|
||||
* The source of the declaration, to be used when raising diagnostics.
|
||||
*
|
||||
* Default: NoTarget
|
||||
*/
|
||||
source?: DiagnosticTarget | typeof NoTarget;
|
||||
/**
|
||||
* The resolution strategy to use if the declared name conflicts with an already declared name.
|
||||
*
|
||||
* Default: "shadow"
|
||||
*/
|
||||
resolutionStrategy?: ResolutionStrategy;
|
||||
}
|
||||
|
||||
const DEFAULT_DECLARATION_OPTIONS: Required<DeclarationOptions> = {
|
||||
source: NoTarget,
|
||||
resolutionStrategy: "shadow",
|
||||
};
|
||||
|
||||
/**
|
||||
* A strategy to use when attempting to resolve naming conflicts. This can be one of the following types:
|
||||
*
|
||||
* - `none`: no attempt will be made to resolve the naming conflict.
|
||||
* - `shadow`: if the scope does not directly declare the name, this declaration will shadow it.
|
||||
* - `prefix`: if the name is already declared, a prefix will be added to the name to resolve the conflict.
|
||||
* - `alt-name`: if the name is already declared, an alternative name will be used to resolve the conflict.
|
||||
*/
|
||||
export type ResolutionStrategy = PrefixResolution | AltNameResolution | "shadow" | "none";
|
||||
|
||||
/**
|
||||
* A resolution strategy that prepends a prefix.
|
||||
*/
|
||||
export interface PrefixResolution {
|
||||
kind: "prefix";
|
||||
/**
|
||||
* The prefix to append to the name.
|
||||
*
|
||||
* Default: "_".
|
||||
*/
|
||||
prefix?: string;
|
||||
/**
|
||||
* Whether or not to repeat the prefix until the conflict is resolved.
|
||||
*/
|
||||
repeated?: boolean;
|
||||
/**
|
||||
* Whether or not the name should shadow existing declarations.
|
||||
*
|
||||
* This setting applies to the primary name as well, so if the primary name is not own-declared in the scope, no
|
||||
* prefix will be added.
|
||||
*/
|
||||
shadow?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolution strategy that attempts to use an alternative name to resolve conflicts.
|
||||
*/
|
||||
export interface AltNameResolution {
|
||||
kind: "alt-name";
|
||||
/**
|
||||
* The alternative name for this declaration.
|
||||
*/
|
||||
altName: string;
|
||||
}
|
||||
|
||||
const NO_PARENT: Scope = {
|
||||
declare() {
|
||||
throw new UnreachableError("Cannot declare in the no-parent scope");
|
||||
},
|
||||
isDeclared() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new scope.
|
||||
*
|
||||
* @param ctx - the JS emitter context.
|
||||
* @param parent - an optional parent scope for this scope. It will consider declarations in the parent scope for some conflicts.
|
||||
*/
|
||||
export function createScope(ctx: JsContext, parent: Scope = NO_PARENT): Scope {
|
||||
const ownDeclarations: Set<string> = new Set();
|
||||
const self: Scope = {
|
||||
declare(primaryName, options = {}) {
|
||||
const { source: target, resolutionStrategy } = { ...DEFAULT_DECLARATION_OPTIONS, ...options };
|
||||
|
||||
if (!self.isDeclared(primaryName)) {
|
||||
ownDeclarations.add(primaryName);
|
||||
return primaryName;
|
||||
}
|
||||
|
||||
// Apply resolution strategy
|
||||
const resolutionStrategyName =
|
||||
typeof resolutionStrategy === "string" ? resolutionStrategy : resolutionStrategy.kind;
|
||||
|
||||
switch (resolutionStrategyName) {
|
||||
case "none":
|
||||
// Report diagnostic and return the name as is.
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "name-conflict",
|
||||
format: {
|
||||
name: primaryName,
|
||||
},
|
||||
target,
|
||||
});
|
||||
return primaryName;
|
||||
case "shadow":
|
||||
// Check to make sure this name isn't an own-declaration, and if not allow it, otherwise raise a diagnostic.
|
||||
if (!ownDeclarations.has(primaryName)) {
|
||||
ownDeclarations.add(primaryName);
|
||||
return primaryName;
|
||||
} else {
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "name-conflict",
|
||||
format: {
|
||||
name: primaryName,
|
||||
},
|
||||
target,
|
||||
});
|
||||
return primaryName;
|
||||
}
|
||||
case "prefix": {
|
||||
const {
|
||||
prefix = "_",
|
||||
repeated = false,
|
||||
shadow = true,
|
||||
} = resolutionStrategy as PrefixResolution;
|
||||
let name = primaryName;
|
||||
|
||||
const isDeclared = shadow ? (name: string) => ownDeclarations.has(name) : self.isDeclared;
|
||||
|
||||
while (isDeclared(name)) {
|
||||
name = prefix + name;
|
||||
|
||||
if (!repeated) break;
|
||||
}
|
||||
|
||||
if (isDeclared(name)) {
|
||||
// We were not able to resolve the conflict with this strategy, so raise a diagnostic.
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "name-conflict",
|
||||
format: {
|
||||
name: name,
|
||||
},
|
||||
target,
|
||||
});
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
ownDeclarations.add(name);
|
||||
return name;
|
||||
}
|
||||
case "alt-name": {
|
||||
const { altName } = resolutionStrategy as AltNameResolution;
|
||||
|
||||
if (!self.isDeclared(altName)) {
|
||||
ownDeclarations.add(altName);
|
||||
return altName;
|
||||
}
|
||||
|
||||
// We were not able to resolve the conflict with this strategy, so raise a diagnostic.
|
||||
reportDiagnostic(ctx.program, {
|
||||
code: "name-conflict",
|
||||
format: {
|
||||
name: altName,
|
||||
},
|
||||
target,
|
||||
});
|
||||
|
||||
return altName;
|
||||
}
|
||||
default:
|
||||
throw new UnreachableError(`Unknown resolution strategy: ${resolutionStrategy}`, {
|
||||
resolutionStrategyName,
|
||||
});
|
||||
}
|
||||
},
|
||||
isDeclared(name) {
|
||||
return ownDeclarations.has(name) || parent.isDeclared(name);
|
||||
},
|
||||
};
|
||||
|
||||
return self;
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
// Copyright (c) Microsoft Corporation
|
||||
// Licensed under the MIT license.
|
||||
|
||||
import { resolvePath } from "@typespec/compiler";
|
||||
import { JsContext, Module, isModule } from "./ctx.js";
|
||||
|
||||
import { emitModuleBody } from "./common/namespace.js";
|
||||
import { OnceQueue, createOnceQueue } from "./util/once-queue.js";
|
||||
|
||||
import * as prettier from "prettier";
|
||||
|
||||
import { EOL } from "os";
|
||||
import path from "path";
|
||||
import { bifilter } from "./util/iter.js";
|
||||
|
||||
/**
|
||||
* Writes the tree of modules to the output directory.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param baseOutputPath - The base output directory to write the module tree to.
|
||||
* @param rootModule - The root module to begin emitting from.
|
||||
* @param format - Whether to format the output using Prettier.
|
||||
*/
|
||||
export async function writeModuleTree(
|
||||
ctx: JsContext,
|
||||
baseOutputPath: string,
|
||||
rootModule: Module,
|
||||
format: boolean
|
||||
): Promise<void> {
|
||||
const queue = createOnceQueue(rootModule);
|
||||
|
||||
while (!queue.isEmpty()) {
|
||||
const module = queue.take()!;
|
||||
await writeModuleFile(ctx, baseOutputPath, module, queue, format);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a single module file to the output directory.
|
||||
*
|
||||
* @param ctx - The emitter context.
|
||||
* @param baseOutputPath - The base output directory to write the module tree to.
|
||||
* @param module - The module to write.
|
||||
* @param queue - The queue of modules to write.
|
||||
* @param format - Whether to format the output using Prettier.
|
||||
*/
|
||||
async function writeModuleFile(
|
||||
ctx: JsContext,
|
||||
baseOutputPath: string,
|
||||
module: Module,
|
||||
queue: OnceQueue<Module>,
|
||||
format: boolean
|
||||
): Promise<void> {
|
||||
const moduleText = [
|
||||
"// Generated by Microsoft TypeSpec",
|
||||
"",
|
||||
...emitModuleBody(ctx, module, queue),
|
||||
];
|
||||
|
||||
const [declaredModules, declaredText] = bifilter(module.declarations, isModule);
|
||||
|
||||
if (declaredText.length === 0) {
|
||||
// Early exit to avoid writing an empty module.
|
||||
return;
|
||||
}
|
||||
|
||||
const isIndex = module.cursor.path.length === 0 || declaredModules.length > 0;
|
||||
|
||||
const moduleRelativePath =
|
||||
module.cursor.path.length > 0
|
||||
? module.cursor.path.join("/") + (isIndex ? "/index.ts" : ".ts")
|
||||
: "index.ts";
|
||||
|
||||
const modulePath = resolvePath(baseOutputPath, moduleRelativePath);
|
||||
|
||||
const text = format
|
||||
? await prettier.format(moduleText.join(EOL), {
|
||||
parser: "typescript",
|
||||
})
|
||||
: moduleText.join(EOL);
|
||||
|
||||
await ctx.program.host.mkdirp(path.dirname(modulePath));
|
||||
await ctx.program.host.writeFile(modulePath, text);
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [{ "path": "../compiler/tsconfig.json" }],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"tsBuildInfoFile": "temp/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "generated-defs/**/*.ts"]
|
||||
}
|
|
@ -47,6 +47,7 @@
|
|||
"@typespec/compiler": "workspace:~",
|
||||
"@typespec/html-program-viewer": "workspace:~",
|
||||
"@typespec/http": "workspace:~",
|
||||
"@typespec/http-server-javascript": "workspace:~",
|
||||
"@typespec/json-schema": "workspace:~",
|
||||
"@typespec/openapi": "workspace:~",
|
||||
"@typespec/openapi3": "workspace:~",
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -19,7 +19,8 @@
|
|||
{ "path": "packages/samples/tsconfig.json" },
|
||||
{ "path": "packages/json-schema/tsconfig.json" },
|
||||
{ "path": "packages/best-practices/tsconfig.json" },
|
||||
{ "path": "packages/xml/tsconfig.json" }
|
||||
{ "path": "packages/xml/tsconfig.json" },
|
||||
{ "path": "packages/http-server-javascript/tsconfig.json" }
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче