1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
//! Generate static HTML documentation for GraphQL APIs.
//!
//!
//! ## Overview
//!
//! [GraphiQL] is great. So are tools like [Altair] and [Insomnia]. But they aren't
//! necessarily enough.
//!
//! `docql` comes in when you want documentation for GraphQL APIs that lives in a
//! shared place. Having HTML documentation allows teams to link to specific
//! objects and fields to enhance conversation, reference the docs when away from
//! the computer, and generally have a place to see the entire GraphQL schema at a
//! glance.
//!
//! [GraphiQL]: https://github.com/graphql/graphiql
//! [Altair]: https://altair.sirmuel.design/
//! [Insomnia]: https://insomnia.rest/graphql/
//!
//! ## Examples
//!
//! * [GitHub v4 API][github v4]: [generated][github v4 generated]
//! * [GraphQL's example Star Wars API][swapi]: [generated][swapi generated]
//!
//! [github v4]: https://docs.github.com/en/graphql
//! [swapi]: https://swapi.graph.cool/
//! [github v4 generated]: https://bryanburgers.github.io/docql/github/
//! [swapi generated]: https://bryanburgers.github.io/docql/swapi/
//!
//!
//! ## Use
//!
//! There are two ways to use `docql`.
//!
//! ### npx
//!
//! The easiest way to get started is to run `docql` off of the npm registry.
//!
//! ```text
//! npx docql -e $API -o ./doc
//! ```
//!
//!
//! ### native binaries
//!
//! If native binaries are more your style and you have access to [Rust]'s `cargo`,
//! you can install with `cargo install`.
//!
//! ```text
//! cargo install docql
//! docql -e $API -o ./doc
//! ```
//!
//! [crates.io]: https://crates.io
//! [Rust]: https://rust-lang.org
//!
//!
//! ## Command line options
//!
//! ```text
//! USAGE:
//!     docql [OPTIONS] --output <path> <--endpoint <url>|--schema <path>>
//!
//! FLAGS:
//!     -h, --help       Prints help information
//!     -V, --version    Prints version information
//!
//! OPTIONS:
//!     -e, --endpoint <url>        The URL of the GraphQL endpoint to document
//!     -x, --header <header>...    Additional headers when executing the GraphQL introspection query (e.g. `-x
//!                                 "Authorization: Bearer abcdef"`
//!     -n, --name <name>           The name to give to the schema (used in the title of the page) [default: GraphQL Schema]
//!     -o, --output <path>         The directory to put the generated documentation
//!     -s, --schema <path>         The output of a GraphQL introspection query already stored locally
//! ```
#![deny(missing_docs)]
use chrono::NaiveDate;
use clap::{App, AppSettings, Arg, ArgGroup};
use futures::stream::{StreamExt as _, TryStreamExt as _};
use std::collections::HashMap;

mod error;
mod handlebars_helpers;
mod renderer;
mod runtime;
mod schema;
mod search_index;
pub use error::{Error, Result};
use renderer::Renderer;
pub use runtime::{GraphqlRequest, Runtime, GRAPHQL_REQUEST, INTROSPECTION_QUERY};

static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),);

/// The primary entrypoint to run the application.
///
/// This function uses the runtime to get arguments, fetch the GraphQL schema, and write out the
/// results to the output directory.
pub async fn main(runtime: impl Runtime) -> Result<()> {
    let args = runtime
        .get_args()
        .await
        .map_err(|err| Error::Args(err.to_string()))?;

    let matches = App::new("docql")
        .version(env!("CARGO_PKG_VERSION"))
        .about("Generate documentation for a GraphQL API")
        .setting(AppSettings::NoBinaryName)
        .arg(
            Arg::with_name("endpoint")
                .short("e")
                .long("endpoint")
                .help("The URL of the GraphQL endpoint to document")
                .takes_value(true)
                .value_name("url")
                .validator(|s| match s.parse::<url::Url>() {
                    Ok(url) => {
                        if url.scheme() == "http" || url.scheme() == "https" {
                            Ok(())
                        } else {
                            Err("Endpoint is not an http or https URL".to_string())
                        }
                    }
                    Err(e) => Err(e.to_string()),
                }),
        )
        .arg(
            Arg::with_name("schema")
                .short("s")
                .long("schema")
                .alias("schema-file")
                .help("The output of a GraphQL introspection query already stored locally")
                .takes_value(true)
                .value_name("path")
        )
        .arg(
            Arg::with_name("output")
                .short("o")
                .long("output")
                .help("The directory to put the generated documentation")
                .required(true)
                .takes_value(true)
                .value_name("path"),
        )
        .arg(
            Arg::with_name("name")
                .short("n")
                .long("name")
                .help("The name to give to the schema (used in the title of the page)")
                .takes_value(true)
                .default_value("GraphQL Schema"),
        )
        .arg(
            Arg::with_name("header")
                .short("x")
                .long("header")
                .help("Additional headers when executing the GraphQL introspection query (e.g. `-x \"Authorization: Bearer abcdef\"`")
                .number_of_values(1)
                .multiple(true)
                .takes_value(true)
                .conflicts_with("schema")
                .validator(|s| {
                    let mut parts = s.splitn(2, ":").skip(1);
                    parts.next().ok_or_else(|| "Header must include a name, a colon, and a value".to_string())?;
                    Ok(())
                })
        )
        .group(
            ArgGroup::with_name("source")
                .args(&["endpoint", "schema"])
                .required(true)
        )
        .get_matches_from_safe(args)?;

    let output = matches.value_of("output").unwrap();
    let name = matches.value_of("name").unwrap();

    let source = if let Some(url) = matches.value_of("endpoint") {
        let mut headers: HashMap<String, String> = HashMap::new();
        headers.insert("user-agent".to_string(), USER_AGENT.to_string());

        if let Some(header_opts) = matches.values_of("header") {
            for header in header_opts {
                // This is known to be safe because we validate it in clap's Arg::validator
                let mut parts = header.splitn(2, ":");
                let name = parts.next().unwrap().trim();
                let value = parts.next().unwrap().trim();
                headers.insert(name.to_string(), value.to_string());
            }
        }

        Source::Endpoint { url, headers }
    } else {
        let path = matches.value_of("schema").unwrap();
        Source::Schema { path }
    };

    let date = runtime
        .date()
        .await
        .map_err(|e| Error::Date(e.to_string()))?;
    let date =
        NaiveDate::parse_from_str(&date, "%Y-%m-%d").map_err(|e| Error::Date(e.to_string()))?;

    let graphql_response = source.get_json(&runtime).await?;
    let schema = graphql_response.data.schema;

    runtime
        .prepare_output_directory(&output)
        .await
        .map_err(|e| Error::PrepareOutputDirectory(output.to_string(), e.to_string()))?;

    let renderer = Renderer::new(name.to_string(), date, &schema)?;

    let index_content = renderer.render_index()?;
    let index_filename = "index.html".to_string();
    runtime
        .write_file(&output, &index_filename, &index_content)
        .await
        .map_err(|e| Error::WriteFile(index_filename, e.to_string()))?;
    let style_filename = "style.css".to_string();
    runtime
        .write_file(
            &output,
            &style_filename,
            include_str!("templates/style.css"),
        )
        .await
        .map_err(|e| Error::WriteFile(style_filename, e.to_string()))?;
    let script_filename = "script.js".to_string();
    runtime
        .write_file(
            &output,
            &script_filename,
            include_str!("templates/script.js"),
        )
        .await
        .map_err(|e| Error::WriteFile(script_filename, e.to_string()))?;

    let search_index = search_index::SearchIndex::build(&schema);
    let search_index = serde_json::to_string_pretty(&search_index)?;
    let search_index_filename = "search-index.json".to_string();
    runtime
        .write_file(&output, &search_index_filename, &search_index)
        .await
        .map_err(|e| Error::WriteFile(search_index_filename, e.to_string()))?;

    futures::stream::iter(&schema.types)
        .map(|t| write_type(&runtime, &output, &renderer, t))
        .buffered(10)
        .try_collect()
        .await?;

    Ok(())
}

enum Source<'a> {
    Endpoint {
        url: &'a str,
        headers: HashMap<String, String>,
    },
    Schema {
        path: &'a str,
    },
}

impl Source<'_> {
    async fn get_json(self, runtime: &impl Runtime) -> Result<schema::GraphQLResponse> {
        match self {
            Self::Endpoint { url, headers } => Self::get_json_endpoint(url, headers, runtime).await,
            Self::Schema { path } => Self::get_json_schema(path, runtime).await,
        }
    }

    async fn get_json_endpoint(
        url: &str,
        headers: HashMap<String, String>,
        runtime: &impl Runtime,
    ) -> Result<schema::GraphQLResponse> {
        let value = runtime
            .query(url, &runtime::GRAPHQL_REQUEST, headers)
            .await
            .map_err(|e| Error::Query(e.to_string()))?;
        let graphql_response: schema::GraphQLResponse = serde_json::from_value(value)?;
        Ok(graphql_response)
    }

    async fn get_json_schema(
        path: &str,
        runtime: &impl Runtime,
    ) -> Result<schema::GraphQLResponse> {
        let s = runtime
            .read_file(path)
            .await
            .map_err(|e| Error::ReadSchemaFile(e.to_string()))?;

        let graphql_response: schema::GraphQLResponse = serde_json::from_str(&s)?;
        Ok(graphql_response)
    }
}

async fn write_type(
    runtime: &impl Runtime,
    output: &str,
    renderer: &Renderer<'_>,
    full_type: &schema::FullType,
) -> Result<()> {
    let file_name = format!("{}.{}.html", full_type.kind.prefix(), full_type.name);

    let content = match full_type.kind {
        schema::Kind::Object => Some(renderer.render_object(&full_type)?),
        schema::Kind::InputObject => Some(renderer.render_input_object(&full_type)?),
        schema::Kind::Scalar => Some(renderer.render_scalar(&full_type)?),
        schema::Kind::Enum => Some(renderer.render_enum(&full_type)?),
        schema::Kind::Interface => Some(renderer.render_interface(&full_type)?),
        schema::Kind::Union => Some(renderer.render_union(&full_type)?),
        schema::Kind::List => None,
        schema::Kind::NonNull => None,
    };

    if let Some(content) = content {
        runtime
            .write_file(output, &file_name, &content)
            .await
            .map_err(|e| Error::WriteFile(file_name, e.to_string()))?;
    }

    Ok(())
}