1#![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
93pub 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 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}