use cufflink_types::ServiceManifest;
use serde_json::{Map, Value};
use std::path::Path;
const DEFAULT_CLASS_NAME: &str = "CufflinkClient";
pub(super) fn write(
dir: &Path,
spec: &Value,
manifest: &ServiceManifest,
class_name: Option<&str>,
esm: bool,
) -> eyre::Result<()> {
let class_name = class_name.unwrap_or(DEFAULT_CLASS_NAME);
if !is_identifier(class_name) {
eyre::bail!("--class-name '{class_name}' is not a TypeScript identifier");
}
let client = if spec.get("servers").is_some() {
typed_client(spec, &manifest.name, class_name)
} else {
generate_typescript_client(spec, &manifest.name, class_name)
};
let written = [
("client.ts", client),
("index.ts", generate_index_ts().to_string()),
]
.into_iter()
.chain(esm.then(|| package_json(dir, manifest)).transpose()?)
.map(|(name, content)| {
let path = dir.join(name);
std::fs::write(&path, content).map(|()| path)
})
.collect::<std::io::Result<Vec<_>>>()?;
println!("Generated TypeScript client:");
written
.iter()
.for_each(|path| println!(" {}", path.display()));
Ok(())
}
fn is_identifier(name: &str) -> bool {
let mut chars = name.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
}
fn package_json(dir: &Path, manifest: &ServiceManifest) -> eyre::Result<(&'static str, String)> {
let existing = std::fs::read_to_string(dir.join("package.json")).ok();
let mut package = match existing {
Some(text) => serde_json::from_str::<Map<String, Value>>(&text)?,
None => Map::from_iter([
(
"name".to_string(),
Value::String(format!("{}-client", manifest.name)),
),
(
"version".to_string(),
Value::String(manifest.version.clone().unwrap_or_else(|| "0.1.0".into())),
),
]),
};
package.insert("type".into(), Value::String("module".into()));
Ok((
"package.json",
format!("{}\n", serde_json::to_string_pretty(&package)?),
))
}
fn generate_index_ts() -> &'static str {
"// Auto-generated by cufflink CLI\nexport * from \"./client.js\";\n"
}
fn generate_typescript_client(
spec: &serde_json::Value,
service_name: &str,
class_name: &str,
) -> String {
let mut out = String::new();
out.push_str(&format!(
"// Auto-generated TypeScript client for {}\n",
service_name
));
out.push_str("// Generated by cufflink CLI\n\n");
if let Some(schemas) = spec["components"]["schemas"].as_object() {
for (name, schema) in schemas {
if name == "Pagination" {
continue;
}
out.push_str(&format!("export interface {} {{\n", name));
if let Some(props) = schema["properties"].as_object() {
for (prop_name, prop_def) in props {
let ts_type = openapi_type_to_ts(prop_def);
let nullable = prop_def["nullable"].as_bool().unwrap_or(false);
if nullable {
out.push_str(&format!(" {}: {} | null;\n", prop_name, ts_type));
} else {
out.push_str(&format!(" {}: {};\n", prop_name, ts_type));
}
}
}
out.push_str("}\n\n");
}
out.push_str("export interface Pagination {\n");
out.push_str(" current_page: number;\n");
out.push_str(" per_page: number;\n");
out.push_str(" total_records: number;\n");
out.push_str(" total_pages: number;\n");
out.push_str("}\n\n");
}
out.push_str(&format!("export class {class_name} {{\n"));
out.push_str(" private baseUrl: string;\n");
out.push_str(" private headers: Record<string, string>;\n\n");
out.push_str(" constructor(baseUrl: string, headers: Record<string, string> = {}) {\n");
out.push_str(" this.baseUrl = baseUrl.replace(/\\/$/, '');\n");
out.push_str(" this.headers = { 'Content-Type': 'application/json', ...headers };\n");
out.push_str(" }\n\n");
out.push_str(
" private async request<T>(method: string, path: string, body?: unknown): Promise<T> {\n",
);
out.push_str(" const resp = await fetch(`${this.baseUrl}${path}`, {\n");
out.push_str(" method,\n");
out.push_str(" headers: this.headers,\n");
out.push_str(" body: body ? JSON.stringify(body) : undefined,\n");
out.push_str(" });\n");
out.push_str(
" if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);\n",
);
out.push_str(" return resp.json() as Promise<T>;\n");
out.push_str(" }\n\n");
if let Some(paths) = spec["paths"].as_object() {
for (path, operations) in paths {
if let Some(ops) = operations.as_object() {
for (method, op) in ops {
let operation_id = op["operationId"].as_str().unwrap_or("unknown");
let fn_name = to_camel_case(operation_id);
let is_custom = op["tags"]
.as_array()
.map(|tags| tags.iter().any(|t| t.as_str() == Some("custom")))
.unwrap_or(false);
if is_custom {
let has_body = op.get("requestBody").is_some();
if has_body {
out.push_str(&format!(
" async {}(data?: Record<string, unknown>): Promise<Record<string, unknown>> {{\n",
fn_name
));
out.push_str(&format!(
" return this.request('{}', '{}', data);\n",
method.to_uppercase(),
path
));
} else {
out.push_str(&format!(
" async {}(params?: Record<string, string>): Promise<Record<string, unknown>> {{\n",
fn_name
));
out.push_str(
" const query = params ? '?' + new URLSearchParams(params).toString() : '';\n"
);
out.push_str(&format!(
" return this.request('GET', `{}${{query}}`);\n",
path
));
}
out.push_str(" }\n\n");
} else {
match method.as_str() {
"get" if !path.contains(":id") => {
let schema_ref = op["responses"]["200"]["content"]
["application/json"]["schema"]["properties"]["results"]
["items"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let return_type = schema_ref.unwrap_or("unknown");
out.push_str(&format!(
" async {}(params?: Record<string, string>): Promise<{{ results: {}[]; pagination: Pagination }}> {{\n",
fn_name, return_type
));
out.push_str(
" const query = params ? '?' + new URLSearchParams(params).toString() : '';\n"
);
out.push_str(&format!(
" return this.request('GET', `{}${{query}}`);\n",
path
));
out.push_str(" }\n\n");
}
"post" => {
let create_ref = op["requestBody"]["content"]["application/json"]
["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let body_type = create_ref.unwrap_or("unknown");
let response_ref = op["responses"]["200"]["content"]
["application/json"]["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let return_type = response_ref.unwrap_or("unknown");
out.push_str(&format!(
" async {}(data: {}): Promise<{}> {{\n",
fn_name, body_type, return_type
));
out.push_str(&format!(
" return this.request('POST', '{}', data);\n",
path
));
out.push_str(" }\n\n");
}
"get" if path.contains(":id") => {
let response_ref = op["responses"]["200"]["content"]
["application/json"]["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let return_type = response_ref.unwrap_or("unknown");
let base_path = path.replace("/:id", "");
out.push_str(&format!(
" async {}(id: string): Promise<{}> {{\n",
fn_name, return_type
));
out.push_str(&format!(
" return this.request('GET', `{}/${{id}}`);\n",
base_path
));
out.push_str(" }\n\n");
}
"put" => {
let body_ref = op["requestBody"]["content"]["application/json"]
["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let body_type = body_ref.unwrap_or("unknown");
let response_ref = op["responses"]["200"]["content"]
["application/json"]["schema"]["$ref"]
.as_str()
.and_then(|r| r.split('/').next_back());
let return_type = response_ref.unwrap_or("unknown");
let base_path = path.replace("/:id", "");
out.push_str(&format!(
" async {}(id: string, data: {}): Promise<{}> {{\n",
fn_name, body_type, return_type
));
out.push_str(&format!(
" return this.request('PUT', `{}/${{id}}`, data);\n",
base_path
));
out.push_str(" }\n\n");
}
"delete" => {
let base_path = path.replace("/:id", "");
out.push_str(&format!(
" async {}(id: string): Promise<void> {{\n",
fn_name
));
out.push_str(&format!(
" await this.request('DELETE', `{}/${{id}}`);\n",
base_path
));
out.push_str(" }\n\n");
}
_ => {}
}
}
}
}
}
}
out.push_str("}\n");
out
}
fn openapi_type_to_ts(prop: &serde_json::Value) -> &str {
match prop["type"].as_str() {
Some("string") => "string",
Some("integer") => "number",
Some("number") => "number",
Some("boolean") => "boolean",
Some("object") => "Record<string, unknown>",
Some("array") => "unknown[]",
_ => "unknown",
}
}
fn to_camel_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = false;
for (i, c) in s.chars().enumerate() {
if c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else if i == 0 {
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}
fn typed_client(spec: &Value, service_name: &str, class_name: &str) -> String {
let declarations: String = spec["components"]["schemas"]
.as_object()
.into_iter()
.flatten()
.map(|(name, schema)| declaration(name, schema))
.collect();
let operations: Vec<String> = spec["paths"]
.as_object()
.into_iter()
.flatten()
.flat_map(|(path, item)| {
item.as_object()
.into_iter()
.flatten()
.map(move |(method, operation)| typed_method(path, method, operation))
})
.collect();
let uses_query = spec["paths"]
.as_object()
.into_iter()
.flatten()
.flat_map(|(_, item)| item.as_object().into_iter().flatten())
.any(|(_, operation)| !parameters(operation, "query").is_empty());
let query_helper = if uses_query { QUERY_HELPER } else { "" };
format!(
"// Auto-generated TypeScript client for {service_name}\n\
// Generated by cufflink CLI\n\n\
{declarations}\
export class {class_name} {{\n\
{TYPED_CLASS_BODY}{query_helper}{}}}\n",
operations.concat()
)
}
const TYPED_CLASS_BODY: &str = r#" private baseUrl: string;
private headers: Record<string, string>;
constructor(baseUrl: string, headers: Record<string, string> = {}) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.headers = { 'Content-Type': 'application/json', ...headers };
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const raw = body instanceof FormData || body instanceof Blob;
const headers = raw
? Object.fromEntries(Object.entries(this.headers).filter(([name]) => name.toLowerCase() !== 'content-type'))
: this.headers;
const resp = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : raw ? (body as BodyInit) : JSON.stringify(body),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
if (resp.status === 204) return undefined as T;
const type = resp.headers.get('content-type') ?? '';
return (type.includes('json') ? resp.json() : resp.blob()) as Promise<T>;
}
"#;
const QUERY_HELPER: &str = r#" private query(params?: Record<string, unknown>): string {
const search = new URLSearchParams();
Object.entries(params ?? {}).forEach(([key, value]) => {
(Array.isArray(value) ? value : [value])
.filter((item) => item !== undefined && item !== null)
.forEach((item) => search.append(key, String(item)));
});
const text = search.toString();
return text ? `?${text}` : '';
}
"#;
fn declaration(name: &str, schema: &Value) -> String {
match schema["properties"].as_object() {
Some(_) if schema.get("anyOf").is_none() && schema.get("oneOf").is_none() => {
format!("export interface {name} {}\n\n", object_type(schema, ""))
}
_ => format!("export type {name} = {};\n\n", ts_type(schema)),
}
}
fn object_type(schema: &Value, indent: &str) -> String {
let required: Vec<&str> = schema["required"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
let fields: String = schema["properties"]
.as_object()
.into_iter()
.flatten()
.map(|(name, property)| {
let optional = if required.contains(&name.as_str()) {
""
} else {
"?"
};
let nullable = if property["nullable"].as_bool().unwrap_or(false) {
" | null"
} else {
""
};
format!(
"{indent} {}{optional}: {}{nullable};\n",
property_name(name),
ts_type(property)
)
})
.collect();
format!("{{\n{fields}{indent}}}")
}
fn property_name(name: &str) -> String {
if is_identifier(name) {
name.to_string()
} else {
Value::String(name.to_string()).to_string()
}
}
fn ts_type(schema: &Value) -> String {
if let Some(reference) = schema["$ref"].as_str() {
return reference
.rsplit('/')
.next()
.unwrap_or("unknown")
.to_string();
}
if let Some(values) = schema["enum"].as_array() {
return union(values.iter().map(Value::to_string));
}
if let Some(value) = schema.get("const") {
return value.to_string();
}
if let Some(branches) = ["anyOf", "oneOf"]
.iter()
.find_map(|key| schema[*key].as_array())
{
return union(branches.iter().map(ts_type));
}
match &schema["type"] {
Value::Array(kinds) => union(kinds.iter().map(|kind| {
let mut single = schema.clone();
single["type"] = kind.clone();
ts_type(&single)
})),
Value::String(kind) => match kind.as_str() {
"string" => "string".into(),
"integer" | "number" => "number".into(),
"boolean" => "boolean".into(),
"null" => "null".into(),
"array" => format!("Array<{}>", ts_type(&schema["items"])),
"object" if schema["properties"].is_object() => object_type(schema, " "),
"object" => match &schema["additionalProperties"] {
Value::Object(values) if !values.is_empty() => {
format!(
"Record<string, {}>",
ts_type(&schema["additionalProperties"])
)
}
_ => "Record<string, unknown>".into(),
},
_ => "unknown".into(),
},
_ => "unknown".into(),
}
}
fn union(members: impl Iterator<Item = String>) -> String {
let members = members.fold(Vec::<String>::new(), |mut unique, member| {
if !unique.contains(&member) {
unique.push(member);
}
unique
});
match members.len() {
0 => "never".into(),
_ => members.join(" | "),
}
}
const RESERVED_ARGUMENTS: [&str; 2] = ["data", "query"];
fn path_argument(raw: &str) -> String {
let name = to_camel_case(raw);
if RESERVED_ARGUMENTS.contains(&name.as_str()) {
format!("{name}Param")
} else {
name
}
}
fn parameters<'a>(operation: &'a Value, location: &str) -> Vec<&'a Value> {
operation["parameters"]
.as_array()
.into_iter()
.flatten()
.filter(|parameter| parameter["in"] == location)
.collect()
}
fn typed_method(path: &str, method: &str, operation: &Value) -> String {
let name = to_camel_case(operation["operationId"].as_str().unwrap_or(method));
let path_params: Vec<(String, String)> = parameters(operation, "path")
.iter()
.filter_map(|parameter| {
parameter["name"]
.as_str()
.map(|raw| (raw.to_string(), ts_type(¶meter["schema"])))
})
.collect();
let query = parameters(operation, "query");
let query_type = (!query.is_empty()).then(|| {
let fields: String = query
.iter()
.filter_map(|parameter| {
let name = parameter["name"].as_str()?;
let optional = if parameter["required"].as_bool().unwrap_or(false) {
""
} else {
"?"
};
Some(format!(
"{}{optional}: {}; ",
property_name(name),
ts_type(¶meter["schema"])
))
})
.collect();
let optional = query
.iter()
.all(|parameter| !parameter["required"].as_bool().unwrap_or(false));
(format!("{{ {}}}", fields), optional)
});
let body = operation["requestBody"]["content"]
.as_object()
.and_then(|content| content.iter().next())
.map(|(media, object)| {
let body_type = match media.as_str() {
"application/json" => ts_type(&object["schema"]),
"multipart/form-data" => "FormData".to_string(),
_ => "Blob".to_string(),
};
let required = operation["requestBody"]["required"]
.as_bool()
.unwrap_or(false);
(body_type, required)
});
let result = result_type(operation);
let arguments: Vec<String> = path_params
.iter()
.map(|(raw, ty)| format!("{}: {ty}", path_argument(raw)))
.chain(
body.iter()
.map(|(ty, required)| format!("data{}: {ty}", if *required { "" } else { "?" })),
)
.chain(
query_type
.iter()
.map(|(ty, optional)| format!("query{}: {ty}", if *optional { "?" } else { "" })),
)
.collect();
let url_path: String = path
.split('/')
.map(
|segment| match segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
Some(raw) => format!("${{encodeURIComponent(String({}))}}", path_argument(raw)),
None => segment.to_string(),
},
)
.collect::<Vec<_>>()
.join("/");
let query_suffix = if query_type.is_some() {
" + this.query(query)"
} else {
""
};
let data = if body.is_some() { ", data" } else { "" };
format!(
" async {name}({}): Promise<{result}> {{\n return this.request<{result}>('{}', `{url_path}`{query_suffix}{data});\n }}\n\n",
arguments.join(", "),
method.to_uppercase(),
)
}
fn result_type(operation: &Value) -> String {
operation["responses"]
.as_object()
.into_iter()
.flatten()
.filter(|(status, _)| status.starts_with('2'))
.min_by(|(a, _), (b, _)| a.cmp(b))
.map(|(_, response)| match response["content"].as_object() {
Some(content) => match content.get("application/json") {
Some(json) if json.get("schema").is_some() => ts_type(&json["schema"]),
_ => "Blob".to_string(),
},
None => "void".to_string(),
})
.unwrap_or_else(|| "void".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn sample_spec() -> serde_json::Value {
let item_ref = json!({ "$ref": "#/components/schemas/Item" });
let json_body = json!({ "content": { "application/json": { "schema": item_ref } } });
json!({
"components": { "schemas": {
"Item": { "properties": {
"id": { "type": "string", "format": "uuid" },
"name": { "type": "string" },
"count": { "type": "integer" },
"note": { "type": "string", "nullable": true }
}}
}},
"paths": {
"/svc/t/s/items": {
"get": {
"operationId": "list_items",
"responses": { "200": { "content": { "application/json": {
"schema": { "properties": { "results": { "items": item_ref } } }
}}}}
},
"post": { "operationId": "create_item", "requestBody": json_body, "responses": { "200": json_body } }
},
"/svc/t/s/items/:id": {
"get": { "operationId": "get_item", "responses": { "200": json_body } },
"put": { "operationId": "update_item", "requestBody": json_body, "responses": { "200": json_body } },
"delete": { "operationId": "delete_item", "responses": {} }
},
"/svc/t/s/_fn/handle_search": { "get": { "operationId": "handle_search", "tags": ["custom"] } },
"/svc/t/s/_fn/handle_do": {
"post": { "operationId": "handle_do", "tags": ["custom"], "requestBody": json_body }
}
}
})
}
#[test]
fn generated_client_is_brace_balanced() {
let out = generate_typescript_client(&sample_spec(), "test-service", DEFAULT_CLASS_NAME);
assert_eq!(
out.matches('{').count(),
out.matches('}').count(),
"unbalanced braces in generated client:\n{out}"
);
}
#[test]
fn request_helper_casts_json_to_generic() {
let out = generate_typescript_client(&sample_spec(), "test-service", DEFAULT_CLASS_NAME);
assert!(
out.contains("return resp.json() as Promise<T>;"),
"request<T> helper is missing the Promise<T> cast:\n{out}"
);
}
#[test]
fn index_reexports_client_with_js_specifier() {
let index = generate_index_ts();
assert!(index.contains("export * from \"./client.js\";"), "{index}");
}
#[test]
fn emits_each_method_kind_and_closes_class() {
let out = generate_typescript_client(&sample_spec(), "test-service", DEFAULT_CLASS_NAME);
for needle in [
"async listItems(",
"async createItem(",
"async getItem(",
"async updateItem(",
"async deleteItem(",
"async handleSearch(",
"async handleDo(",
] {
assert!(out.contains(needle), "missing method `{needle}`:\n{out}");
}
assert!(out.contains("export class CufflinkClient {"));
assert!(out.trim_end().ends_with('}'));
}
fn relative_spec() -> serde_json::Value {
let note = json!({"$ref": "#/components/schemas/ApiNote"});
json!({
"servers": [{"url": "https://api.example.com/api"}],
"components": {"schemas": {
"ApiNote": {"type": "object", "required": ["id", "kind"], "properties": {
"id": {"type": "string"},
"kind": {"$ref": "#/components/schemas/NoteKind"},
"parent": {"type": ["string", "null"]},
"tags": {"type": "array", "items": {"type": "string"}},
"extra": {},
"created-at": {"type": "string"},
}},
"NoteKind": {"type": "string", "enum": ["Standard", "Callback"]},
}},
"paths": {
"/notes/{note_id}": {
"put": {
"operationId": "update_note_handler",
"parameters": [
{"name": "note_id", "in": "path", "required": true, "schema": {"type": "string"}},
{"name": "force", "in": "query", "required": false, "schema": {"type": ["boolean", "null"]}},
],
"requestBody": {"content": {"application/json": {"schema": note}}, "required": true},
"responses": {"200": {"description": "OK", "content": {"application/json": {"schema": note}}},
"404": {"description": "Not Found"}},
},
"delete": {
"operationId": "delete_note_handler",
"parameters": [{"name": "note_id", "in": "path", "required": true, "schema": {"type": "string"}}],
"responses": {"204": {"description": "No Content"}},
},
},
"/notes/{note_id}/pdf": {"get": {
"operationId": "note_pdf",
"parameters": [{"name": "note_id", "in": "path", "required": true, "schema": {"type": "string"}}],
"responses": {"200": {"description": "PDF", "content": {"application/pdf": {}}}},
}},
"/imports": {"post": {
"operationId": "import_notes",
"requestBody": {"content": {"multipart/form-data": {"schema": {"type": "object"}}}, "required": true},
"responses": {"201": {"description": "Created"}},
}},
"/files/{data}/{query}": {"put": {
"operationId": "put_file",
"parameters": [
{"name": "data", "in": "path", "required": true, "schema": {"type": "string"}},
{"name": "query", "in": "path", "required": true, "schema": {"type": "string"}},
],
"requestBody": {"content": {"application/octet-stream": {}}},
"responses": {"200": {"description": "OK", "content": {"text/csv": {}}}},
}},
},
})
}
#[test]
fn relative_specs_get_a_typed_client() {
let out = typed_client(&relative_spec(), "loans-service", "NotesClient");
for needle in [
"export interface ApiNote {\n \"created-at\"?: string;\n extra?: unknown;\n id: string;\n kind: NoteKind;\n parent?: string | null;\n tags?: Array<string>;\n}",
"export type NoteKind = \"Standard\" | \"Callback\";",
"export class NotesClient {",
" async updateNoteHandler(noteId: string, data: ApiNote, query?: { force?: boolean | null; }): Promise<ApiNote> {\n return this.request<ApiNote>('PUT', `/notes/${encodeURIComponent(String(noteId))}` + this.query(query), data);",
" async deleteNoteHandler(noteId: string): Promise<void> {",
" async notePdf(noteId: string): Promise<Blob> {\n return this.request<Blob>('GET', `/notes/${encodeURIComponent(String(noteId))}/pdf`);\n }",
" async importNotes(data: FormData): Promise<void> {\n return this.request<void>('POST', `/imports`, data);\n }",
" async putFile(dataParam: string, queryParam: string, data?: Blob): Promise<Blob> {\n return this.request<Blob>('PUT', `/files/${encodeURIComponent(String(dataParam))}/${encodeURIComponent(String(queryParam))}`, data);\n }",
" private query(params?: Record<string, unknown>): string {",
] {
assert!(out.contains(needle), "missing:\n{needle}\n---\n{out}");
}
assert_eq!(out.matches('{').count(), out.matches('}').count(), "{out}");
}
#[test]
fn unions_name_each_member_once_in_order() {
let members = ["b", "a", "b", "c", "a"].map(String::from);
assert_eq!(union(members.into_iter()), "b | a | c");
assert_eq!(union(std::iter::empty()), "never");
}
#[test]
fn the_query_helper_is_emitted_only_when_used() {
let mut spec = relative_spec();
spec["paths"]["/notes/{note_id}"]["put"]["parameters"]
.as_array_mut()
.expect("parameters")
.truncate(1);
let out = typed_client(&spec, "loans-service", DEFAULT_CLASS_NAME);
assert!(!out.contains("private query("), "{out}");
}
#[test]
fn class_names_are_identifiers_and_esm_marks_the_package_a_module() {
assert!(is_identifier("NotesClient") && is_identifier("_c$1"));
assert!(!is_identifier("1Client") && !is_identifier("notes-client") && !is_identifier(""));
let dir = std::env::temp_dir().join(format!("cufflink-esm-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("dir");
let manifest = ServiceManifest {
name: "loans-service".into(),
version: Some("1.2.0".into()),
..Default::default()
};
let (_, fresh) = package_json(&dir, &manifest).expect("package");
assert_eq!(
serde_json::from_str::<Value>(&fresh).expect("json"),
json!({"name": "loans-service-client", "version": "1.2.0", "type": "module"})
);
std::fs::write(
dir.join("package.json"),
r#"{"name": "@acme/notes", "private": true}"#,
)
.expect("write");
let (_, merged) = package_json(&dir, &manifest).expect("package");
assert_eq!(
serde_json::from_str::<Value>(&merged).expect("json"),
json!({"name": "@acme/notes", "private": true, "type": "module"})
);
let _ = std::fs::remove_dir_all(dir);
}
}