Skip to main content

docql/
lib.rs

1//! Generate static HTML documentation for GraphQL APIs.
2//!
3//!
4//! ## Overview
5//!
6//! [GraphiQL] is great. So are tools like [Altair] and [Insomnia]. But they aren't
7//! necessarily enough.
8//!
9//! `docql` comes in when you want documentation for GraphQL APIs that lives in a
10//! shared place. Having HTML documentation allows teams to link to specific
11//! objects and fields to enhance conversation, reference the docs when away from
12//! the computer, and generally have a place to see the entire GraphQL schema at a
13//! glance.
14//!
15//! [GraphiQL]: https://github.com/graphql/graphiql
16//! [Altair]: https://altair.sirmuel.design/
17//! [Insomnia]: https://insomnia.rest/graphql/
18//!
19//! ## Examples
20//!
21//! * [GitHub v4 API][github v4]: [generated][github v4 generated]
22//! * [GraphQL's example Star Wars API][swapi]: [generated][swapi generated]
23//!
24//! [github v4]: https://docs.github.com/en/graphql
25//! [swapi]: https://swapi.graph.cool/
26//! [github v4 generated]: https://bryanburgers.github.io/docql/github/
27//! [swapi generated]: https://bryanburgers.github.io/docql/swapi/
28//!
29//!
30//! ## Use
31//!
32//! There are two ways to use `docql`.
33//!
34//! ### npx
35//!
36//! The easiest way to get started is to run `docql` off of the npm registry.
37//!
38//! ```text
39//! npx docql -e $API -o ./doc
40//! ```
41//!
42//!
43//! ### native binaries
44//!
45//! If native binaries are more your style and you have access to [Rust]'s `cargo`,
46//! you can install with `cargo install`.
47//!
48//! ```text
49//! cargo install docql
50//! docql -e $API -o ./doc
51//! ```
52//!
53//! [crates.io]: https://crates.io
54//! [Rust]: https://rust-lang.org
55//!
56//!
57//! ## Command line options
58//!
59//! ```text
60//! USAGE:
61//!     docql [OPTIONS] --output <path> <--endpoint <url>|--schema <path>>
62//!
63//! FLAGS:
64//!     -h, --help       Prints help information
65//!     -V, --version    Prints version information
66//!
67//! OPTIONS:
68//!     -e, --endpoint <url>        The URL of the GraphQL endpoint to document
69//!     -x, --header <header>...    Additional headers when executing the GraphQL introspection query (e.g. `-x
70//!                                 "Authorization: Bearer abcdef"`
71//!     -n, --name <name>           The name to give to the schema (used in the title of the page) [default: GraphQL Schema]
72//!     -o, --output <path>         The directory to put the generated documentation
73//!     -s, --schema <path>         The output of a GraphQL introspection query already stored locally
74//! ```
75#![deny(missing_docs)]
76use chrono::NaiveDate;
77use clap::{App, AppSettings, Arg, ArgGroup};
78use futures::stream::{StreamExt as _, TryStreamExt as _};
79use std::collections::HashMap;
80
81mod error;
82mod handlebars_helpers;
83mod renderer;
84mod runtime;
85mod schema;
86mod search_index;
87pub use error::{Error, Result};
88use renderer::Renderer;
89pub use runtime::{GraphqlRequest, Runtime, GRAPHQL_REQUEST, INTROSPECTION_QUERY};
90
91static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);
92
93/// The primary entrypoint to run the application.
94///
95/// This function uses the runtime to get arguments, fetch the GraphQL schema, and write out the
96/// results to the output directory.
97pub async fn main(runtime: impl Runtime) -> Result<()> {
98    let args = runtime
99        .get_args()
100        .await
101        .map_err(|err| Error::Args(err.to_string()))?;
102
103    let matches = App::new("docql")
104        .version(env!("CARGO_PKG_VERSION"))
105        .about("Generate documentation for a GraphQL API")
106        .setting(AppSettings::NoBinaryName)
107        .arg(
108            Arg::with_name("endpoint")
109                .short("e")
110                .long("endpoint")
111                .help("The URL of the GraphQL endpoint to document")
112                .takes_value(true)
113                .value_name("url")
114                .validator(|s| match s.parse::<url::Url>() {
115                    Ok(url) => {
116                        if url.scheme() == "http" || url.scheme() == "https" {
117                            Ok(())
118                        } else {
119                            Err("Endpoint is not an http or https URL".to_string())
120                        }
121                    }
122                    Err(e) => Err(e.to_string()),
123                }),
124        )
125        .arg(
126            Arg::with_name("schema")
127                .short("s")
128                .long("schema")
129                .alias("schema-file")
130                .help("The output of a GraphQL introspection query already stored locally")
131                .takes_value(true)
132                .value_name("path")
133        )
134        .arg(
135            Arg::with_name("output")
136                .short("o")
137                .long("output")
138                .help("The directory to put the generated documentation")
139                .required(true)
140                .takes_value(true)
141                .value_name("path"),
142        )
143        .arg(
144            Arg::with_name("name")
145                .short("n")
146                .long("name")
147                .help("The name to give to the schema (used in the title of the page)")
148                .takes_value(true)
149                .default_value("GraphQL Schema"),
150        )
151        .arg(
152            Arg::with_name("header")
153                .short("x")
154                .long("header")
155                .help("Additional headers when executing the GraphQL introspection query (e.g. `-x \"Authorization: Bearer abcdef\"`")
156                .number_of_values(1)
157                .multiple(true)
158                .takes_value(true)
159                .conflicts_with("schema")
160                .validator(|s| {
161                    let mut parts = s.splitn(2, ":").skip(1);
162                    parts.next().ok_or_else(|| "Header must include a name, a colon, and a value".to_string())?;
163                    Ok(())
164                })
165        )
166        .group(
167            ArgGroup::with_name("source")
168                .args(&["endpoint", "schema"])
169                .required(true)
170        )
171        .get_matches_from_safe(args)?;
172
173    let output = matches.value_of("output").unwrap();
174    let name = matches.value_of("name").unwrap();
175
176    let source = if let Some(url) = matches.value_of("endpoint") {
177        let mut headers: HashMap<String, String> = HashMap::new();
178        headers.insert("user-agent".to_string(), USER_AGENT.to_string());
179
180        if let Some(header_opts) = matches.values_of("header") {
181            for header in header_opts {
182                // This is known to be safe because we validate it in clap's Arg::validator
183                let mut parts = header.splitn(2, ":");
184                let name = parts.next().unwrap().trim();
185                let value = parts.next().unwrap().trim();
186                headers.insert(name.to_string(), value.to_string());
187            }
188        }
189
190        Source::Endpoint { url, headers }
191    } else {
192        let path = matches.value_of("schema").unwrap();
193        Source::Schema { path }
194    };
195
196    let date = runtime
197        .date()
198        .await
199        .map_err(|e| Error::Date(e.to_string()))?;
200    let date =
201        NaiveDate::parse_from_str(&date, "%Y-%m-%d").map_err(|e| Error::Date(e.to_string()))?;
202
203    let graphql_response = source.get_json(&runtime).await?;
204    let schema = graphql_response.data.schema;
205
206    runtime
207        .prepare_output_directory(&output)
208        .await
209        .map_err(|e| Error::PrepareOutputDirectory(output.to_string(), e.to_string()))?;
210
211    let renderer = Renderer::new(name.to_string(), date, &schema)?;
212
213    let index_content = renderer.render_index()?;
214    let index_filename = "index.html".to_string();
215    runtime
216        .write_file(&output, &index_filename, &index_content)
217        .await
218        .map_err(|e| Error::WriteFile(index_filename, e.to_string()))?;
219    let style_filename = "style.css".to_string();
220    runtime
221        .write_file(
222            &output,
223            &style_filename,
224            include_str!("templates/style.css"),
225        )
226        .await
227        .map_err(|e| Error::WriteFile(style_filename, e.to_string()))?;
228    let script_filename = "script.js".to_string();
229    runtime
230        .write_file(
231            &output,
232            &script_filename,
233            include_str!("templates/script.js"),
234        )
235        .await
236        .map_err(|e| Error::WriteFile(script_filename, e.to_string()))?;
237
238    let search_index = search_index::SearchIndex::build(&schema);
239    let search_index = serde_json::to_string_pretty(&search_index)?;
240    let search_index_filename = "search-index.json".to_string();
241    runtime
242        .write_file(&output, &search_index_filename, &search_index)
243        .await
244        .map_err(|e| Error::WriteFile(search_index_filename, e.to_string()))?;
245
246    futures::stream::iter(&schema.types)
247        .map(|t| write_type(&runtime, &output, &renderer, t))
248        .buffered(10)
249        .try_collect()
250        .await?;
251
252    Ok(())
253}
254
255enum Source<'a> {
256    Endpoint {
257        url: &'a str,
258        headers: HashMap<String, String>,
259    },
260    Schema {
261        path: &'a str,
262    },
263}
264
265impl Source<'_> {
266    async fn get_json(self, runtime: &impl Runtime) -> Result<schema::GraphQLResponse> {
267        match self {
268            Self::Endpoint { url, headers } => Self::get_json_endpoint(url, headers, runtime).await,
269            Self::Schema { path } => Self::get_json_schema(path, runtime).await,
270        }
271    }
272
273    async fn get_json_endpoint(
274        url: &str,
275        headers: HashMap<String, String>,
276        runtime: &impl Runtime,
277    ) -> Result<schema::GraphQLResponse> {
278        let value = runtime
279            .query(url, &runtime::GRAPHQL_REQUEST, headers)
280            .await
281            .map_err(|e| Error::Query(e.to_string()))?;
282        let graphql_response: schema::GraphQLResponse = serde_json::from_value(value)?;
283        Ok(graphql_response)
284    }
285
286    async fn get_json_schema(
287        path: &str,
288        runtime: &impl Runtime,
289    ) -> Result<schema::GraphQLResponse> {
290        let s = runtime
291            .read_file(path)
292            .await
293            .map_err(|e| Error::ReadSchemaFile(e.to_string()))?;
294
295        let graphql_response: schema::GraphQLResponse = serde_json::from_str(&s)?;
296        Ok(graphql_response)
297    }
298}
299
300async fn write_type(
301    runtime: &impl Runtime,
302    output: &str,
303    renderer: &Renderer<'_>,
304    full_type: &schema::FullType,
305) -> Result<()> {
306    let file_name = format!("{}.{}.html", full_type.kind.prefix(), full_type.name);
307
308    let content = match full_type.kind {
309        schema::Kind::Object => Some(renderer.render_object(&full_type)?),
310        schema::Kind::InputObject => Some(renderer.render_input_object(&full_type)?),
311        schema::Kind::Scalar => Some(renderer.render_scalar(&full_type)?),
312        schema::Kind::Enum => Some(renderer.render_enum(&full_type)?),
313        schema::Kind::Interface => Some(renderer.render_interface(&full_type)?),
314        schema::Kind::Union => Some(renderer.render_union(&full_type)?),
315        schema::Kind::List => None,
316        schema::Kind::NonNull => None,
317    };
318
319    if let Some(content) = content {
320        runtime
321            .write_file(output, &file_name, &content)
322            .await
323            .map_err(|e| Error::WriteFile(file_name, e.to_string()))?;
324    }
325
326    Ok(())
327}