diff --git a/src/bin/forge/capnweb.rs b/src/bin/forge/capnweb.rs new file mode 100644 index 000000000..364a2d7ad --- /dev/null +++ b/src/bin/forge/capnweb.rs @@ -0,0 +1,495 @@ +//! The Cap'n Web server generator, following rfcs/capnweb.md. +//! +//! The server is a thin layer over the TypeScript SDK that the `ts` target +//! generates: every group gets an `Api`, an RpcTarget that holds the SDK +//! client of the group and forwards each call to it. The server imports the +//! SDK from `#sdk`, which the package maps to the SDK with subpath imports +//! in its package.json: +//! +//! ```json +//! "imports": { "#sdk": "@cloudflare/sdk", "#sdk/*": "@cloudflare/sdk/*" } +//! ``` +//! +//! The server has the same modules as the SDK, at the same paths: each +//! group's directory has a server.ts, which declares its `Api`, and an +//! index.ts, which exports only that `Api`. Any `Api` can be the main +//! object of a session, wrapping a client of its SDK module: +//! +//! ```ts +//! newWorkersRpcResponse(request, new Api(createClient(options))); +//! ``` + +use std::collections::HashSet; + +use forge::ts::{File, Fragment, Ident, Module, PREAMBLE, frag, p}; +use forge::{Err, Forge, Group, Method, Plugin}; + +use crate::ts::client::{Request, group_name, method_doc, method_name, request_name, response_name, skipped}; +use crate::ts::types::{doc, identifier, key}; +use crate::ts::{self, Node}; + +/// Generates a Cap'n Web server. +pub struct Generator; + +impl Plugin for Generator { + type File = ts::File; + + /// Returns the server.ts and index.ts of the root, and the options.ts + /// every server imports. + fn generate(&self, forge: &Forge) -> Result, Err> { + let root = Node::Root(forge); + let (_, source) = OPTIONS_SOURCE.split_once('\n').unwrap_or_default(); + let options = ts::File::Runtime { + path: OPTIONS.as_str().to_owned(), + content: format!("{PREAMBLE}{source}"), + }; + Ok([ts::File::Ts(server(root)?), ts::File::Ts(index(root)), options].into_iter()) + } + + fn generate_group(&self, group: Group<'_>) -> Result, Err> { + let group = Node::Group(group); + Ok([ts::File::Ts(server(group)?), ts::File::Ts(index(group))].into_iter()) + } +} + +/// The Cap'n Web package. +const CAPNWEB: Module = Module::new("capnweb"); + +/// The module of the root that every server imports its options from. +const OPTIONS: Module = Module::new("options.ts"); + +/// The source of options.ts, which is the same for every API: the options +/// every method of a server takes, which are those of the SDK that Cap'n +/// Web can carry, and how they become the options of the SDK. No module +/// exports it. +/// +/// Its first line, a `@ts-expect-error` for `#sdk`, which resolves only in +/// the package that holds the server, isn't generated. +const OPTIONS_SOURCE: &str = include_str!("capnweb/options.ts"); + +/// The SDK's root. +const SDK: Module = Module::new("#sdk"); + +/// The name every module declares and exports its server under, as every +/// module of the SDK does its Client. +const API: &str = "Api"; + +/// Returns the class every server extends. +fn rpc_target() -> Ident { + CAPNWEB.ident("RpcTarget") +} + +/// Returns the options every method of a server takes. +fn options_type() -> Ident { + OPTIONS.ident("Options").ty() +} + +/// Returns the function that turns the options into those of the SDK. +fn request_options() -> Ident { + OPTIONS.ident("requestOptions") +} + +/// Returns the module of the SDK that exports the Client of `node` and the +/// request and response types of its methods: `#sdk` for the root, and +/// `#sdk/` followed by the directory of the group otherwise, such as +/// `#sdk/projects/members`. +fn sdk(node: Node<'_>) -> Module { + match node { + Node::Root(_) => SDK, + Node::Group(_) => Module::from(format!("{SDK}/{}", node.dir())), + } +} + +/// Returns the `Api` of group `g`, imported under the name of the group, +/// such as `DnsApi`. +fn api_ident(g: Group<'_>) -> Ident { + let alias = format!("{}{API}", identifier(g.name().pascal())); + Module::group(g).sub("server.ts").ident(API).alias(alias) +} + +/// The names that Cap'n Web does not deliver to a target: the properties of +/// Object.prototype, which it never follows, and the members of its stubs, +/// which a client reads on the stub rather than calling. +const RESERVED: [&str; 18] = [ + "constructor", + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toLocaleString", + "toString", + "valueOf", + "__proto__", + "__defineGetter__", + "__defineSetter__", + "__lookupGetter__", + "__lookupSetter__", + "then", + "catch", + "finally", + "map", + "dup", + "onRpcBroken", +]; + +/// Returns the server.ts of `node`, which declares its `Api`: its methods +/// that the SDK keeps, then its subgroups. +fn server(node: Node<'_>) -> Result { + let methods: Vec> = node.methods().into_iter().filter(|&m| !skipped(m)).collect(); + let groups = node.children(); + check(node, &methods, &groups)?; + + let mut f = File::new(node.dir().sub("server.ts")); + let client = sdk(node).ident("Client").ty(); + let what = match node { + Node::Root(_) => "the API".to_owned(), + Node::Group(_) => format!("the {} group of the API", node.path()), + }; + f.doc(&format!( + "Serves {what} over Cap'n Web with a Client of the SDK. \ + Each method takes the request of the SDK's method, and the headers and query parameters to send with it. \ + Each subgroup is served by the Api of the subgroup." + )); + p!(f, "export class ", f.ident(API), " extends ", rpc_target(), " {"); + f.indent(|f| { + p!(f, "readonly #client: ", client, ";"); + p!(f); + p!(f, "constructor(client: ", client, ") {"); + f.indent(|f| { + p!(f, "super();"); + p!(f, "this.#client = client;"); + }); + p!(f, "}"); + for &m in &methods { + p!(f); + method(f, node, m); + } + for &sub in &groups { + p!(f); + subgroup(f, sub); + } + }); + p!(f, "}"); + Ok(f) +} + +/// Reports the members of the server of `node` that it can't serve, naming +/// the group and the member: a name that Cap'n Web reserves, and a method +/// and a subgroup with the same name. +fn check(node: Node<'_>, methods: &[Method<'_>], groups: &[Group<'_>]) -> Result<(), Err> { + let path = node.path(); + let prefix = if path.is_empty() { + String::new() + } else { + format!("group {path:?}: ") + }; + let reserved = |what: &str, name: &str| { + RESERVED + .contains(&name) + .then(|| format!("{prefix}{what} {name:?} cannot be served over Cap'n Web, which reserves the name")) + }; + let mut errs = Vec::new(); + let method_names: HashSet = methods.iter().map(|&m| method_name(m)).collect(); + for &m in methods { + errs.extend(reserved("method", &method_name(m))); + } + for &g in groups { + let name = group_name(g); + errs.extend(reserved("group", &name)); + if method_names.contains(&name) { + errs.push(format!( + "{prefix}a method and a group are both named {name:?}, which the Cap'n Web server cannot serve" + )); + } + } + if errs.is_empty() { + Ok(()) + } else { + Err(errs.join("\n").into()) + } +} + +/// Declares a method of a server that calls the method of the Client. +fn method(f: &mut File, node: Node<'_>, m: Method<'_>) { + let name = method_name(m); + f.doc(&doc(&method_doc(m), m.is_deprecated())); + p!( + f, + key(&name), + "(", + params(node, m), + "): Promise<", + response_ident(node, m), + "> {" + ); + f.indent(|f| { + p!( + f, + "return this.#client", + access(&name), + "(request, ", + request_options(), + "(options));" + ); + }); + p!(f, "}"); +} + +/// Declares the getter of a server that returns the `Api` of a subgroup, +/// wrapping the SDK client of the subgroup. +fn subgroup(f: &mut File, sub: Group<'_>) { + let name = group_name(sub); + let api = api_ident(sub); + p!(f, "get ", key(&name), "(): ", api, " {"); + f.indent(|f| { + p!(f, "return new ", api, "(this.#client", access(&name), ");"); + }); + p!(f, "}"); +} + +/// Returns the parameters of a method of a server: the request of the SDK's +/// method, optional when it is optional there, and the options. +fn params(node: Node<'_>, m: Method<'_>) -> Fragment { + frag!( + "request", + if Request::new(m).optional() { "?: " } else { ": " }, + sdk(node).ident(request_name(m)).ty(), + ", options?: ", + options_type() + ) +} + +fn response_ident(node: Node<'_>, m: Method<'_>) -> Ident { + sdk(node).ident(response_name(m)).ty() +} + +/// Returns the property access of `name`: `.name`, or `["name"]` when it +/// is not an identifier. +fn access(name: &str) -> String { + let key = key(name); + if key == name { + format!(".{name}") + } else { + format!("[{key}]") + } +} + +/// Returns the index.ts of `node`, the module of its server, which exports +/// only its `Api`. +fn index(node: Node<'_>) -> File { + let mut f = File::new(node.dir().sub("index.ts")); + p!(f, "export { ", API, r#" } from "./server.js";"#); + f +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use forge::ts::PREAMBLE; + use forge::{Forge, Plugin}; + + use super::Generator; + + /// Returns the forge of an OpenAPI document with `paths`. + fn build(paths: &str) -> Forge { + let doc = format!(r#"{{"openapi": "3.0.3", "info": {{"title": "t", "version": "1"}}, "paths": {paths}}}"#); + serde_json::from_str(&doc).unwrap() + } + + /// Returns the content of every file of the server, by path, without + /// the preamble. + fn generate(forge: &Forge) -> BTreeMap { + forge::run(forge, &Generator) + .unwrap() + .into_iter() + .map(|file| { + let content = forge::File::content(&file).unwrap(); + let content = String::from_utf8(content.into_owned()).unwrap(); + let content = content.strip_prefix(PREAMBLE).unwrap().to_owned(); + (forge::File::path(&file).to_owned(), content) + }) + .collect() + } + + fn lines(lines: &[&str]) -> String { + let mut out = lines.join("\n"); + out.push('\n'); + out + } + + /// A JSON response with an object body. + const OK: &str = r#""responses": {"200": {"description": "ok", "content": {"application/json": {"schema": {"type": "object", "properties": {"id": {"type": "string"}}}}}}}"#; + + /// A path parameter `id`. + const ID: &str = r#""parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}]"#; + + #[test] + fn generate_server() { + let forge = build(&format!( + r#"{{ + "/projects/{{id}}": {{"get": {{"x-fern-sdk-group-name": "projects", "x-fern-sdk-method-name": "get", "summary": "Gets a project.", {ID}, {OK}}}}}, + "/projects": {{"get": {{"x-fern-sdk-group-name": "projects", "x-fern-sdk-method-name": "list", + "parameters": [{{"name": "cursor", "in": "query", "schema": {{"type": "string"}}}}], {OK}}}}}, + "/projects/{{id}}/archive": {{"post": {{"x-fern-sdk-group-name": "projects", "x-fern-sdk-method-name": "archive", {ID}, {OK}}}}}, + "/projects/{{id}}/members": {{"get": {{"x-fern-sdk-group-name": "projects.members", "x-fern-sdk-method-name": "list", {ID}, {OK}}}}}, + "/projects/upload": {{"post": {{"x-fern-sdk-group-name": "projects", "x-fern-sdk-method-name": "upload", + "requestBody": {{"content": {{"application/octet-stream": {{"schema": {{"type": "string", "format": "binary"}}}}}}}}, {OK}}}}} + }}"# + )); + let files = generate(&forge); + assert_eq!( + files.keys().collect::>(), + [ + "index.ts", + "options.ts", + "projects/index.ts", + "projects/members/index.ts", + "projects/members/server.ts", + "projects/server.ts", + "server.ts" + ] + ); + + // The root Api serves the top-level groups. + assert_eq!( + files["server.ts"], + lines(&[ + r##"import type { Client } from "#sdk";"##, + r#"import { RpcTarget } from "capnweb";"#, + "", + r#"import { Api as ProjectsApi } from "./projects/server.js";"#, + "", + "/**", + " * Serves the API over Cap'n Web with a Client of the SDK. Each method takes the", + " * request of the SDK's method, and the headers and query parameters to send", + " * with it. Each subgroup is served by the Api of the subgroup.", + " */", + "export class Api extends RpcTarget {", + " readonly #client: Client;", + "", + " constructor(client: Client) {", + " super();", + " this.#client = client;", + " }", + "", + " get projects(): ProjectsApi {", + " return new ProjectsApi(this.#client.projects);", + " }", + "}", + ]) + ); + + // A method that is not JSON is left out, as the SDK leaves it out. + // The methods come first, then the subgroups. + assert_eq!( + files["projects/server.ts"], + lines(&[ + "import type {", + " ArchiveRequest,", + " ArchiveResponse,", + " Client,", + " GetRequest,", + " GetResponse,", + " ListRequest,", + " ListResponse,", + r##"} from "#sdk/projects";"##, + r#"import { RpcTarget } from "capnweb";"#, + "", + r#"import { type Options, requestOptions } from "../options.js";"#, + r#"import { Api as MembersApi } from "./members/server.js";"#, + "", + "/**", + " * Serves the projects group of the API over Cap'n Web with a Client of the SDK.", + " * Each method takes the request of the SDK's method, and the headers and query", + " * parameters to send with it. Each subgroup is served by the Api of the", + " * subgroup.", + " */", + "export class Api extends RpcTarget {", + " readonly #client: Client;", + "", + " constructor(client: Client) {", + " super();", + " this.#client = client;", + " }", + "", + " archive(request: ArchiveRequest, options?: Options): Promise {", + " return this.#client.archive(request, requestOptions(options));", + " }", + "", + " /**", + " * Gets a project.", + " */", + " get(request: GetRequest, options?: Options): Promise {", + " return this.#client.get(request, requestOptions(options));", + " }", + "", + " list(request?: ListRequest, options?: Options): Promise {", + " return this.#client.list(request, requestOptions(options));", + " }", + "", + " get members(): MembersApi {", + " return new MembersApi(this.#client.members);", + " }", + "}", + ]) + ); + assert!( + files["projects/members/server.ts"].contains(r##"} from "#sdk/projects/members";"##), + "{}", + files["projects/members/server.ts"] + ); + + // Every module exports only its Api. + for index in ["index.ts", "projects/index.ts", "projects/members/index.ts"] { + assert_eq!( + files[index], + lines(&[r#"export { Api } from "./server.js";"#]), + "{index}" + ); + } + assert!( + files["options.ts"].starts_with("import type { RequestOptions } from '#sdk';\n"), + "{}", + files["options.ts"] + ); + } + + #[test] + fn reserved() { + // Cap'n Web never delivers a call to then or toString. + let forge = build(&format!( + r#"{{ + "/a": {{"get": {{"x-fern-sdk-group-name": "a", "x-fern-sdk-method-name": "then", {OK}}}}}, + "/b": {{"get": {{"x-fern-sdk-group-name": "to-string", "x-fern-sdk-method-name": "get", {OK}}}}} + }}"# + )); + let root = Generator.generate(&forge).err().unwrap().to_string(); + assert!( + root.contains(r#"group "toString" cannot be served over Cap'n Web"#), + "{root}" + ); + let a = forge.groups().find(|g| g.name().camel() == "a").unwrap(); + let group = Generator.generate_group(a).err().unwrap().to_string(); + assert!( + group.contains(r#"group "a": method "then" cannot be served over Cap'n Web"#), + "{group}" + ); + } + + #[test] + fn method_and_group_with_the_same_name() { + // The SDK serves both as one property, which Cap'n Web can't type. + let forge = build(&format!( + r#"{{ + "/projects/{{id}}/archive": {{"post": {{"x-fern-sdk-group-name": "projects", "x-fern-sdk-method-name": "archive", {ID}, {OK}}}}}, + "/projects/{{id}}/archive/restore": {{"post": {{"x-fern-sdk-group-name": "projects.archive", "x-fern-sdk-method-name": "restore", {ID}, {OK}}}}} + }}"# + )); + let projects = forge.groups().find(|g| g.name().camel() == "projects").unwrap(); + let err = Generator.generate_group(projects).err().unwrap().to_string(); + assert!( + err.contains(r#"group "projects": a method and a group are both named "archive""#), + "{err}" + ); + } +} diff --git a/src/bin/forge/capnweb/options.ts b/src/bin/forge/capnweb/options.ts new file mode 100644 index 000000000..faafa89a7 --- /dev/null +++ b/src/bin/forge/capnweb/options.ts @@ -0,0 +1,34 @@ +//@ts-expect-error // Generated code will resolve #sdk, this is just avoid the import error here. +import type { RequestOptions } from '#sdk'; + +/** + * The options of a call to a method of an Api. + */ +export interface Options { + /** + * Headers sent with the request, which replace those of the same name set on the client. + */ + headers?: Headers | Record | [string, string][]; + /** + * Query parameters appended after those of the client. A list of pairs repeats a key in order. Cap'n Web doesn't + * carry a URLSearchParams, so the server makes one for the SDK. + */ + query?: Record | [string, string][]; +} + +/** + * Returns options as the options of a method of the SDK. + */ +export function requestOptions(options: Options | undefined): RequestOptions | undefined { + if (options === undefined || options === null) { + return undefined; + } + const out: RequestOptions = {}; + if (options.headers !== undefined) { + out.headers = options.headers; + } + if (options.query !== undefined) { + out.query = new URLSearchParams(options.query); + } + return out; +} diff --git a/src/bin/forge/main.rs b/src/bin/forge/main.rs index 6781dd8ea..e91476155 100644 --- a/src/bin/forge/main.rs +++ b/src/bin/forge/main.rs @@ -2,6 +2,7 @@ //! //! ```text //! forge openapi.json --target ts --out sdk +//! forge openapi.json --target capnweb --out server //! forge openapi.json --target wasm://plugin.wasm --out sdk //! ``` @@ -11,8 +12,9 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use clap::Parser; -use forge::{Err, Forge}; +use forge::{Err, Forge, Plugin}; +mod capnweb; mod ts; /// Generates an SDK from an OpenAPI document. @@ -21,8 +23,10 @@ mod ts; struct Args { /// The OpenAPI JSON document, or - to read it from standard input. spec: PathBuf, - /// The language of the SDK: `ts` for TypeScript, or `wasm://PATH` for - /// the plugin compiled to a WebAssembly component at PATH. + /// The language of the SDK: `ts` for TypeScript, `capnweb` for a Cap'n + /// Web server of the TypeScript SDK, which it imports from `#sdk`, or + /// `wasm://PATH` for the plugin compiled to a WebAssembly component at + /// PATH. #[arg(long, value_parser = Target::parse)] target: Target, /// The directory to write the SDK to. @@ -35,6 +39,8 @@ struct Args { enum Target { /// The TypeScript generator. Ts, + /// The Cap'n Web server generator. + Capnweb, /// A plugin compiled to a WebAssembly component, at the path. #[cfg(feature = "wasm")] Wasm(PathBuf), @@ -42,11 +48,13 @@ enum Target { impl Target { fn parse(s: &str) -> Result { - if s == "ts" { - return Ok(Target::Ts); + match s { + "ts" => return Ok(Target::Ts), + "capnweb" => return Ok(Target::Capnweb), + _ => {} } let Some(path) = s.strip_prefix("wasm://") else { - return Err("possible values: ts, wasm://PATH".to_owned()); + return Err("possible values: ts, capnweb, wasm://PATH".to_owned()); }; if path.is_empty() { return Err("wasm:// needs the path of a plugin, as in wasm://plugin.wasm".to_owned()); @@ -71,10 +79,8 @@ fn main() -> ExitCode { /// Generates the SDK that `args` ask for. fn run(args: Args) -> Result<(), Err> { match args.target { - Target::Ts => { - let forge = read(&args.spec)?; - forge::write(&args.out, forge::run(&forge, &ts::Generator)?) - } + Target::Ts => generate(&ts::Generator, &args.spec, &args.out), + Target::Capnweb => generate(&capnweb::Generator, &args.spec, &args.out), #[cfg(feature = "wasm")] Target::Wasm(path) => { // Compiling the plugin and reading the document are both slow, @@ -86,6 +92,10 @@ fn run(args: Args) -> Result<(), Err> { } } +fn generate(plugin: &impl Plugin, spec: &Path, out: &Path) -> Result<(), Err> { + forge::write(out, forge::run(&read(spec)?, plugin)?) +} + /// Reads the document at `path`, or from standard input when `path` is `-`. fn read(path: &Path) -> Result { let parse = || -> Result { diff --git a/src/bin/forge/ts.rs b/src/bin/forge/ts.rs index 7e35e516c..f8ae40b95 100644 --- a/src/bin/forge/ts.rs +++ b/src/bin/forge/ts.rs @@ -1,5 +1,4 @@ -//! The TypeScript SDK generator, ported from Go's `internal/ts` and -//! following the specs in specs/ts. +//! The TypeScript SDK generator, following the specs in specs/ts. //! //! Every group gets a client.ts, a models.ts and an index.ts in its //! directory. The root also gets the runtime library, in `_/`. @@ -9,12 +8,12 @@ use std::borrow::Cow; use forge::ts::{self, Ident, Module, PREAMBLE}; use forge::{Err, Forge, Group, Method, Model, Plugin}; -mod client; +pub(crate) mod client; mod codec; mod index; mod models; mod operation; -mod types; +pub(crate) mod types; /// Generates a TypeScript SDK. pub struct Generator; @@ -54,7 +53,8 @@ fn files(node: Node<'_>) -> Result, Err> { pub enum File { /// A generated file. Ts(ts::File), - /// A file of the runtime library, after the preamble. + /// A file with fixed content, such as one of the runtime library: its + /// path, and its content after the preamble. Runtime { path: String, content: String }, } @@ -79,14 +79,14 @@ impl forge::File for File { // `Plugin::generate` could pass in. Then every function here takes a // `Group`. #[derive(Clone, Copy)] -enum Node<'f> { +pub(crate) enum Node<'f> { Root(&'f Forge), Group(Group<'f>), } impl<'f> Node<'f> { /// Returns the directory of the node's files. - fn dir(self) -> Module { + pub(crate) fn dir(self) -> Module { match self { Node::Root(_) => Module::ROOT, Node::Group(group) => Module::group(group), @@ -103,7 +103,7 @@ impl<'f> Node<'f> { /// Returns the names of the groups down to the node, in camelCase and /// separated by dots, for errors. - fn path(self) -> String { + pub(crate) fn path(self) -> String { match self { Node::Root(_) => String::new(), Node::Group(group) => group.path().map(|name| name.camel()).collect::>().join("."), @@ -112,7 +112,7 @@ impl<'f> Node<'f> { /// Returns the methods directly in the node, sorted by name. The root /// has none. - fn methods(self) -> Vec> { + pub(crate) fn methods(self) -> Vec> { match self { Node::Root(_) => Vec::new(), Node::Group(group) => group.methods().collect(), @@ -120,7 +120,7 @@ impl<'f> Node<'f> { } /// Returns the groups directly in the node, sorted by name. - fn children(self) -> Vec> { + pub(crate) fn children(self) -> Vec> { match self { Node::Root(forge) => forge.groups().filter(|g| g.parent().is_none()).collect(), Node::Group(group) => group.children().collect(), diff --git a/src/bin/forge/ts/client.rs b/src/bin/forge/ts/client.rs index f91d5adb4..530a56875 100644 --- a/src/bin/forge/ts/client.rs +++ b/src/bin/forge/ts/client.rs @@ -344,7 +344,7 @@ pub(super) fn response(m: Method<'_>) -> (Vec>, bool) { /// The request of a method: its parameters that have a field, and its JSON /// body. -pub(super) struct Request<'f> { +pub(crate) struct Request<'f> { /// The parameters, path parameters first. pub(super) params: Vec>, pub(super) body: Option>, @@ -373,7 +373,7 @@ impl<'f> Request<'f> { /// query parameter whose name is not valid has no field, and one named /// like another parameter gets a `$`. A parameter named `body` next to a /// body that is not flat gets a `$` too. - fn new(m: Method<'f>) -> Request<'f> { + pub(crate) fn new(m: Method<'f>) -> Request<'f> { let request = m.request(); let object = match request { Some(Type::Object(object)) => Some(object), @@ -424,7 +424,7 @@ impl<'f> Request<'f> { /// Reports whether the request can be left out: no parameter is /// required, and an empty object is a body. - fn optional(&self) -> bool { + pub(crate) fn optional(&self) -> bool { let params = !self.params.iter().any(|p| p.field.is_required()); let body = match &self.body { None => true, @@ -462,11 +462,11 @@ fn response_ident(f: &File, m: Method<'_>) -> Ident { f.ident(response_name(m)) } -pub(super) fn request_name(m: Method<'_>) -> String { +pub(crate) fn request_name(m: Method<'_>) -> String { format!("{}Request", identifier(m.name().pascal())) } -pub(super) fn response_name(m: Method<'_>) -> String { +pub(crate) fn response_name(m: Method<'_>) -> String { format!("{}Response", identifier(m.name().pascal())) } @@ -476,16 +476,16 @@ pub(super) fn operation_ident(f: &File, m: Method<'_>) -> Ident { } /// Returns the property of a method and a group in the Client. -fn method_name(m: Method<'_>) -> String { +pub(crate) fn method_name(m: Method<'_>) -> String { identifier(m.name().camel()) } -fn group_name(g: Group<'_>) -> String { +pub(crate) fn group_name(g: Group<'_>) -> String { identifier(g.name().camel()) } /// Returns the documentation of `m`: its summary, then its description. -fn method_doc(m: Method<'_>) -> String { +pub(crate) fn method_doc(m: Method<'_>) -> String { let mut text = m.summary().trim().to_owned(); let description = m.description().trim(); if !description.is_empty() && description != text { @@ -500,7 +500,7 @@ fn method_doc(m: Method<'_>) -> String { /// Reports whether `m` is left out of the SDK: its request body, or a /// success response, has content, and none of it is JSON /// (specs/13-non-json-operations.md). -pub(super) fn skipped(m: Method<'_>) -> bool { +pub(crate) fn skipped(m: Method<'_>) -> bool { if let Some(body) = m.body() && body.json().is_none() && body.contents().len() > 0 diff --git a/src/bin/forge/ts/types.rs b/src/bin/forge/ts/types.rs index 728595025..a137b8f75 100644 --- a/src/bin/forge/ts/types.rs +++ b/src/bin/forge/ts/types.rs @@ -116,7 +116,7 @@ pub(super) fn member(field: Field<'_>) -> Fragment { /// Returns the documentation of a declaration, with `@deprecated` after it /// when it is deprecated. -pub(super) fn doc(text: &str, deprecated: bool) -> String { +pub(crate) fn doc(text: &str, deprecated: bool) -> String { if !deprecated { return text.to_owned(); } @@ -183,7 +183,7 @@ pub(super) fn all_optional(t: Type<'_>) -> bool { /// Prefixes `s` with `_` if it starts with a digit, so it's a valid /// identifier. -pub(super) fn identifier(s: String) -> String { +pub(crate) fn identifier(s: String) -> String { if s.starts_with(|c: char| c.is_ascii_digit()) { format!("_{s}") } else { @@ -216,7 +216,7 @@ pub(super) fn is_identifier(s: &str) -> bool { } /// Returns a property name, quoted when it is not an identifier. -pub(super) fn key(name: &str) -> String { +pub(crate) fn key(name: &str) -> String { if is_identifier(name) { name.to_owned() } else { @@ -224,9 +224,9 @@ pub(super) fn key(name: &str) -> String { } } -/// Returns `s` as a string literal, escaped as Go's `encoding/json` does -/// without HTML escaping. -pub(super) fn js_string(s: &str) -> String { +/// Returns `s` as a string literal: quotes, backslashes, control characters, +/// U+2028 and U+2029 are escaped, and nothing else is. +pub(crate) fn js_string(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { diff --git a/tests/cli.rs b/tests/cli.rs index 6b85c5fc4..213bb13a4 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -41,7 +41,7 @@ fn flags_are_required() { fn unknown_target() { let err = failure(forge(&["-", "--target", "go", "--out", "sdk"], SPEC)); assert!(err.contains("invalid value 'go'"), "{err}"); - assert!(err.contains("possible values: ts"), "{err}"); + assert!(err.contains("possible values: ts, capnweb"), "{err}"); } #[test] @@ -79,3 +79,20 @@ fn ts() { ); std::fs::remove_dir_all(&out).unwrap(); } + +#[test] +fn capnweb() { + let out = std::env::temp_dir().join(format!("forge-cli-capnweb-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + let result = forge(&["-", "--target", "capnweb", "--out", out.to_str().unwrap()], SPEC); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + for name in ["index.ts", "options.ts", "server.ts", "a/index.ts", "a/server.ts"] { + assert!(out.join(name).is_file(), "{name} is missing"); + } + let server = std::fs::read_to_string(out.join("a/server.ts")).unwrap(); + assert!( + server.contains("get(request?: GetRequest, options?: Options): Promise {"), + "{server}" + ); + std::fs::remove_dir_all(&out).unwrap(); +}