Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# Changelog

All notable changes to this project will be documented in this file.
## [1.15.0] - 2026-09-29

### Features

- Emit `docs/services.all.json` with frontend-facing and backend-only endpoints

## [1.13.1] - 2026-07-26

### Documentation
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[package]
name = "endpoint-gen"
version = "1.14.0"
version = "1.15.0"
edition = "2024"
repository = "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/pathscale/EndpointGen/"
description = "Schema-first code generator for WebSocket RPC services: turns declarative RON endpoint definitions into Rust models, docs, MCP tool schemas, and optional OpenAPI 3.1 / AsyncAPI 3.0 documents."
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ endpoint-gen --config-dir <path/to/config> --output-dir <path/to/project>
|---|---|---|
| `generated/model.rs` | yes | Rust types, method codes, handler scaffolding. Gitignored in our repos. |
| `docs/services.json` | **yes** | **Machine-readable endpoint description in our own format** — see below. |
| `docs/services.all.json` | **yes** | Same format as `services.json`, with every endpoint, including backend-only endpoints. |
| `docs/<service>_mcp_tools.json` | yes | Exactly what a server reports via MCP `tools/list`. |
| `docs/README.md` | yes | Human-facing reference. |
| `docs/error_codes/error_codes.md` | yes | The error-code catalog. |
Expand Down Expand Up @@ -82,9 +83,10 @@ Reach for `--asyncapi` when a consumer *outside* your control needs to read the
and a bespoke format would be the obstacle. Both describe the same endpoints; pick by
audience.

One behavioural difference worth knowing: `services.json` contains **only
`frontend_facing` endpoints**, always. The specification documents contain everything
unless you pass `--public-only`.
`services.json` contains **only `frontend_facing` endpoints**, always. Use
`services.all.json` for tooling that exercises a running backend and needs backend-only
endpoints as well. Both files include the same enums and structs. The specification
documents contain everything unless you pass `--public-only`.

### `--check`

Expand Down
121 changes: 120 additions & 1 deletion src/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,36 @@ pub fn gen_services_docs(docs: &Data) -> eyre::Result<()> {
"structs": structs,
}),
)?;

let all_services = docs
.services
.clone()
.into_iter()
.map(|service| {
let endpoints: Vec<EndpointSchema> =
service.endpoints.into_iter().map(|endpoint| endpoint.schema).collect();
Service::new(service.name, service.id, endpoints)
})
.filter(|service| !service.endpoints.is_empty())
.collect::<Vec<Service>>();

let all_docs_filename = docs.project_root.join("docs").join("services.all.json");
let mut all_docs_file = File::create(&all_docs_filename)
.with_context(|| format!("Failed to create docs file: {}", all_docs_filename.display()))?;

serde_json::to_writer_pretty(
&mut all_docs_file,
&json!({
"services": all_services,
"enums": doc_enums(docs),
"structs": docs
.structs
.clone()
.into_iter()
.map(|struct_element| struct_element.inner)
.collect::<Vec<Type>>(),
}),
)?;
Ok(())
}

Expand Down Expand Up @@ -488,7 +518,96 @@ actually return.
mod tests {
use super::*;
use crate::definitions::{EndpointSchemaElement, RustGenConfig};
use endpoint_libs::model::Field;
use endpoint_libs::model::{EnumVariant, Field};

#[test]
fn services_json_stays_frontend_only_and_services_all_json_includes_every_endpoint() {
let dir = std::env::temp_dir().join(format!("endpointgen-services-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();

let endpoint = |name: &str, code: u32, frontend_facing: bool| EndpointSchemaElement {
frontend_facing,
config: RustGenConfig::default(),
schema: EndpointSchema::new(name, code, vec![], vec![]).with_description(format!("{name} endpoint.")),
};
let data = Data {
project_name: "test".into(),
project_root: dir.clone(),
output_dir: dir.clone(),
services: vec![
GenService::new(
"user".into(),
1,
vec![
endpoint("UserGetProfile", 10010, true),
endpoint("UserIngest", 10011, false),
],
),
GenService::new("watcher".into(), 2, vec![endpoint("WatcherIngest", 20010, false)]),
],
enums: vec![EnumElement {
config: RustGenConfig::default(),
inner: Type::enum_("role", vec![EnumVariant::new("Admin", 1)]),
}],
structs: vec![StructElement {
config: RustGenConfig::default(),
inner: Type::struct_("UserInfo", vec![Field::new("id", Type::Int64)]),
}],
error_codes: vec![ErrorCodeSchema::new("not_found", 404, "The resource was not found.")],
};

gen_services_docs(&data).unwrap();

let services_json = std::fs::read_to_string(dir.join("docs").join("services.json")).unwrap();
let services_all_json = std::fs::read_to_string(dir.join("docs").join("services.all.json")).unwrap();
let public: serde_json::Value = serde_json::from_str(&services_json).unwrap();
let all: serde_json::Value = serde_json::from_str(&services_all_json).unwrap();

// Match the previous services.json payload and pretty-printing exactly.
let expected_services = data
.services
.clone()
.into_iter()
.map(|service| {
let endpoints = service
.endpoints
.into_iter()
.filter(|endpoint| endpoint.frontend_facing)
.map(|endpoint| endpoint.schema)
.collect();
Service::new(service.name, service.id, endpoints)
})
.filter(|service| !service.endpoints.is_empty())
.collect::<Vec<Service>>();
let expected_structs = data
.structs
.clone()
.into_iter()
.map(|struct_element| struct_element.inner)
.collect::<Vec<Type>>();
let expected_services_json = serde_json::to_string_pretty(&json!({
"services": expected_services,
"enums": doc_enums(&data),
"structs": expected_structs,
}))
.unwrap();
assert_eq!(services_json, expected_services_json);

assert_eq!(public["services"].as_array().unwrap().len(), 1);
assert_eq!(public["services"][0]["name"], json!("user"));
assert_eq!(public["services"][0]["endpoints"].as_array().unwrap().len(), 1);
assert_eq!(public["services"][0]["endpoints"][0]["name"], json!("UserGetProfile"));

assert_eq!(all["services"].as_array().unwrap().len(), 2);
assert_eq!(all["services"][0]["endpoints"].as_array().unwrap().len(), 2);
assert_eq!(all["services"][0]["endpoints"][1]["name"], json!("UserIngest"));
assert_eq!(all["services"][1]["name"], json!("watcher"));
assert_eq!(all["services"][1]["endpoints"][0]["name"], json!("WatcherIngest"));
assert_eq!(all["enums"], public["enums"]);
assert_eq!(all["structs"], public["structs"]);

std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn mcp_tools_json_is_written_per_service() {
Expand Down
Loading