From 298b5ce9976fec7791e655101833409a0a8c7b97 Mon Sep 17 00:00:00 2001 From: Eddie Kohler Date: Mon, 16 Sep 2024 11:17:01 -0400 Subject: [PATCH] API spec can read Markdown descriptions --- batch/apispec.php | 124 ++++++++++++++++++++++++++++++++++------- devel/apidoc/index.md | 6 ++ etc/openapi.json | 27 ++++++++- src/settinginfoset.php | 39 +++++++------ 4 files changed, 155 insertions(+), 41 deletions(-) create mode 100644 devel/apidoc/index.md diff --git a/batch/apispec.php b/batch/apispec.php index 4913e3278d..5043ab748a 100644 --- a/batch/apispec.php +++ b/batch/apispec.php @@ -28,6 +28,10 @@ class APISpec_Batch { private $setj_schemas; /** @var object */ private $setj_parameters; + /** @var object */ + private $setj_tags; + /** @var array> */ + public $description_map; /** @var string */ private $output_file = "-"; /** @var bool */ @@ -39,6 +43,8 @@ class APISpec_Batch { /** @var bool */ private $override_schema; /** @var bool */ + private $override_description; + /** @var bool */ private $sort; /** @var array */ private $tag_order; @@ -55,6 +61,7 @@ function __construct(Conf $conf, $arg) { $this->conf = $conf; if (isset($arg["x"])) { $conf->set_opt("apiFunctions", null); + $conf->set_opt("apiDescriptions", null); } $this->user = $conf->root_user(); @@ -65,10 +72,17 @@ function __construct(Conf $conf, $arg) { "components" => (object) [ "schemas" => (object) [], "parameters" => (object) [] - ] + ], + "tags" => (object) [] ]; $this->setj_schemas = $this->setj->components->schemas; $this->setj_parameters = $this->setj->components->parameters; + $this->setj_tags = $this->setj->tags; + + $this->description_map = []; + foreach ([["?devel/apidoc/*.md"], $conf->opt("apiDescriptions")] as $desc) { + expand_json_includes_callback($desc, [$this, "_add_description_item"], "APISpec_Batch::parse_description_markdown"); + } if (isset($arg["i"])) { if ($arg["i"] === "-") { @@ -89,9 +103,40 @@ function __construct(Conf $conf, $arg) { $this->override_ref = isset($arg["override-ref"]); $this->override_tags = isset($arg["override-tags"]); $this->override_schema = isset($arg["override-schema"]); + $this->override_description = !isset($arg["no-override-description"]); $this->sort = isset($arg["sort"]); } + function _add_description_item($xt) { + if (isset($xt->name) && is_string($xt->name)) { + $this->description_map[$xt->name][] = $xt; + return true; + } + return false; + } + + static function parse_description_markdown($s) { + if (!str_starts_with($s, "#")) { + return null; + } + $m = preg_split('/^\#\s+([^\n]*?)\s*\n/m', $s, -1, PREG_SPLIT_DELIM_CAPTURE); + $xs = []; + for ($i = 1; $i < count($m); $i += 2) { + $x = ["name" => simplify_whitespace($m[$i])]; + $d = cleannl(ltrim($m[$i + 1])); + if (str_starts_with($d, "> ")) { + preg_match('/\A(?:^> .*?\n)+/m', $d, $mx); + $x["summary"] = simplify_whitespace(str_replace("\n> ", "", substr($mx[0], 2))); + $d = ltrim(substr($d, strlen($mx[0]))); + } + if ($d !== "") { + $x["description"] = $d; + } + $xs[] = (object) $x; + } + return $xs; + } + /** @return int */ function run() { $mj = $this->j; @@ -99,6 +144,7 @@ function run() { $info = $mj->info = $mj->info ?? (object) []; $info->title = $info->title ?? "HotCRP"; $info->version = $info->version ?? "0.1"; + $this->merge_description("info", $info); // initialize paths $this->paths = $mj->paths = $mj->paths ?? (object) []; @@ -214,7 +260,6 @@ static private function parse_parameters($j) { /** @param string $fn */ private function expand_paths($fn) { - $getj = null; foreach (["GET", "POST"] as $method) { if (!($uf = $this->conf->api($fn, null, $method))) { continue; @@ -240,11 +285,14 @@ private function expand_paths($fn) { private function expand_path_method($path, $method, $known, $uf) { $pathj = $this->paths->$path = $this->paths->$path ?? (object) []; $pathj->__path = $path; - $this->setj->paths->$path = $this->setj->paths->$path ?? (object) []; + if (!isset($this->setj->paths->$path)) { + $this->merge_description($path, $pathj); + $this->setj->paths->$path = (object) []; + } $lmethod = strtolower($method); $xj = $pathj->$lmethod = $pathj->$lmethod ?? (object) []; $this->setj->paths->$path->$lmethod = true; - $this->expand_metadata($xj, $uf, "{$path}.{$lmethod}"); + $this->expand_metadata($xj, $uf, "{$lmethod} {$path}"); $this->expand_request($xj, $known, $uf, "{$path}.{$lmethod}"); $this->expand_response($xj, $uf); } @@ -253,12 +301,16 @@ private function expand_path_method($path, $method, $known, $uf) { * @param object $uf * @param string $path */ private function expand_metadata($xj, $uf, $path) { + $this->merge_description($path, $xj); if (isset($uf->tags) && (!isset($xj->tags) || $this->override_tags)) { $xj->tags = $uf->tags; } else if (isset($uf->tags) && $uf->tags !== $xj->tags) { fwrite(STDERR, "{$path}: tags differ, expected " . json_encode($xj->tags) . "\n"); } foreach ($xj->tags ?? [] as $tag) { + if (isset($this->setj_tags->$tag)) { + continue; + } $tags = $this->j->tags = $this->j->tags ?? []; $i = 0; while ($i !== count($tags) && $tags[$i]->name !== $tag) { @@ -269,6 +321,26 @@ private function expand_metadata($xj, $uf, $path) { "name" => $tag ]; } + $this->merge_description($tag, $this->j->tags[$i]); + $this->setj_tags->$tag = true; + } + } + + private function merge_description($name, $xj) { + if (!isset($this->description_map[$name])) { + return; + } + $xtp = new XtParams($this->conf, null); + $dj = $xtp->search_name($this->description_map, $name); + if ($dj + && isset($dj->summary) + && ($this->override_description || ($xj->summary ?? "") === "")) { + $xj->summary = $dj->summary; + } + if ($dj + && isset($dj->description) + && ($this->override_description || ($xj->description ?? "") === "")) { + $xj->description = $dj->description; } } @@ -279,29 +351,34 @@ private function resolve_common_schema($name) { $compj = $this->j->components = $this->j->components ?? (object) []; $this->schemas = $compj->schemas = $compj->schemas ?? (object) []; } - if (!isset($this->schemas->$name) - || ($this->override_schema && !isset($this->setj_schemas->$name))) { + $nj = $this->schemas->$name ?? null; + if (!$nj || ($this->override_schema && !isset($this->setj_schemas->$name))) { if ($name === "pid") { - $this->schemas->$name = (object) [ + $nj = (object) [ "type" => "integer", "description" => "Submission ID", "minimum" => 1 ]; } else if ($name === "rid") { - $this->schemas->$name = (object) [ + $nj = (object) [ "type" => "integer", "description" => "Review ID" ]; } else if ($name === "ok") { - return (object) ["type" => "boolean"]; + $nj = (object) [ + "type" => "boolean", + "description" => "Success marker" + ]; } else if ($name === "message_list") { - $this->schemas->$name = (object) [ + $nj = (object) [ "type" => "list", + "description" => "Diagnostic list", "items" => $this->resolve_common_schema("message") ]; } else if ($name === "message") { - $this->schemas->$name = (object) [ + $nj = (object) [ "type" => "object", + "description" => "Diagnostic", "required" => ["status"], "properties" => (object) [ "field" => (object) ["type" => "string"], @@ -313,7 +390,7 @@ private function resolve_common_schema($name) { ] ]; } else if ($name === "minimal_response") { - $this->schemas->$name = (object) [ + $nj = (object) [ "type" => "object", "required" => ["ok"], "properties" => (object) [ @@ -322,7 +399,7 @@ private function resolve_common_schema($name) { ] ]; } else if ($name === "error_response") { - $this->schemas->$name = (object) [ + $nj = (object) [ "type" => "object", "required" => ["ok"], "properties" => (object) [ @@ -334,6 +411,10 @@ private function resolve_common_schema($name) { } else { assert(false); } + $this->schemas->$name = $nj; + } + if (!isset($this->setj_schemas->$name)) { + $this->merge_description("schema {$name}", $nj); $this->setj_schemas->$name = true; } return (object) ["\$ref" => "#/components/schemas/{$name}"]; @@ -346,31 +427,31 @@ private function resolve_common_param($name) { $compj = $this->j->components = $this->j->components ?? (object) []; $this->parameters = $compj->parameters = $compj->parameters ?? (object) []; } - if (!isset($this->parameters->$name) - || ($this->override_schema && !isset($this->setj_parameters->$name))) { + $nj = $this->parameters->$name ?? null; + if ($nj === null || ($this->override_schema && !isset($this->setj_parameters->$name))) { if ($name === "p.path") { - $this->parameters->$name = (object) [ + $nj = (object) [ "name" => "p", "in" => "path", "required" => true, "schema" => $this->resolve_common_schema("pid") ]; } else if ($name === "p" || $name === "p.opt") { - $this->parameters->$name = (object) [ + $nj = (object) [ "name" => "p", "in" => "query", "required" => $name === "p", "schema" => $this->resolve_common_schema("pid") ]; } else if ($name === "r" || $name === "r.opt") { - $this->parameters->$name = (object) [ + $nj = (object) [ "name" => "r", "in" => "query", "required" => $name === "r", "schema" => $this->resolve_common_schema("rid") ]; } else if ($name === "redirect") { - $this->parameters->$name = (object) [ + $nj = (object) [ "name" => "redirect", "in" => "query", "required" => false, @@ -379,6 +460,10 @@ private function resolve_common_param($name) { } else { assert(false); } + $this->parameters->$name = $nj; + } + if (!isset($this->setj_parameters->$name)) { + $this->merge_description("parameter {$name}", $nj); $this->setj_parameters->$name = true; } return (object) ["\$ref" => "#/components/parameters/{$name}"]; @@ -604,6 +689,7 @@ static function make_args($argv) { "override-ref Overwrite conflicting \$refs in input", "override-tags", "override-schema", + "no-override-description", "sort", "o:,output: =FILE Write specification to FILE" )->description("Generate an OpenAPI specification. diff --git a/devel/apidoc/index.md b/devel/apidoc/index.md new file mode 100644 index 0000000000..c5a90e5bff --- /dev/null +++ b/devel/apidoc/index.md @@ -0,0 +1,6 @@ +# info + +> HotCRP conference management software API + +[HotCRP](https://github.com/kohler/hotcrp/) is conference review software. It +is open source; a supported version runs on [hotcrp.com](https://hotcrp.com/). diff --git a/etc/openapi.json b/etc/openapi.json index 915af7a481..de63fcaffc 100644 --- a/etc/openapi.json +++ b/etc/openapi.json @@ -2,7 +2,9 @@ "openapi": "3.1.0", "info": { "title": "HotCRP", - "version": "0.1" + "version": "0.1", + "description": "[HotCRP](https://github.com/kohler/hotcrp/) is open-source conference review\nsoftware, and runs [hotcrp.com](https://hotcrp.com/).\n", + "summary": "HotCRP conference view software API" }, "paths": { "/paper": { @@ -3900,6 +3902,7 @@ }, "message": { "type": "object", + "description": "Diagnostic", "required": [ "status" ], @@ -3928,6 +3931,7 @@ }, "message_list": { "type": "list", + "description": "Diagnostic list", "items": { "$ref": "#/components/schemas/message" } @@ -3968,7 +3972,26 @@ "type": "integer", "description": "Review ID" } - } + }, + "securitySchemes": { + "apiToken": { + "type": "http", + "scheme": "bearer", + "description": "API token created via Profile > Developer" + }, + "session": { + "type": "apiKey", + "in": "cookie", + "name": "hotcrpsession", + "description": "Web sessions use a cookie. The name of the cookie varies by installation; `hotcrpcookie` is used on hotcrp.com. Operations other than GET and HEAD also require a `post` query parameter" + } + }, + "security": [ + { + "apiToken": [], + "session": [] + } + ] }, "tags": [ { diff --git a/src/settinginfoset.php b/src/settinginfoset.php index 31af0a4c1a..c93913b2db 100644 --- a/src/settinginfoset.php +++ b/src/settinginfoset.php @@ -363,28 +363,27 @@ function member_list($pfx) { } static function parse_description_markdown($s) { - if (str_starts_with($s, "#")) { - $m = preg_split('/^#\s+([\w$\/]+)\s*\n/m', $s, -1, PREG_SPLIT_DELIM_CAPTURE); - $xs = []; - for ($i = 1; $i < count($m); $i += 2) { - $x = []; - $key = $m[$i]; - $x[strpos($key, "\$") === false ? "name" : "name_pattern"] = $key; - $d = cleannl(ltrim($m[$i + 1])); - if (str_starts_with($d, "> ")) { - preg_match('/\A(?:^> .*?\n)+/m', $d, $mx); - $x["summary"] = "<3>" . simplify_whitespace(str_replace("\n> ", "", substr($mx[0], 2))); - $d = ltrim(substr($d, strlen($mx[0]))); - } - if ($d !== "") { - $x["description"] = "<3>" . $d; - } - $xs[] = (object) $x; - } - return $xs; - } else { + if (!str_starts_with($s, "#")) { return null; } + $m = preg_split('/^#\s+([\w$\/]+)\s*\n/m', $s, -1, PREG_SPLIT_DELIM_CAPTURE); + $xs = []; + for ($i = 1; $i < count($m); $i += 2) { + $x = []; + $key = $m[$i]; + $x[strpos($key, "\$") === false ? "name" : "name_pattern"] = $key; + $d = cleannl(ltrim($m[$i + 1])); + if (str_starts_with($d, "> ")) { + preg_match('/\A(?:^> .*?\n)+/m', $d, $mx); + $x["summary"] = "<3>" . simplify_whitespace(str_replace("\n> ", "", substr($mx[0], 2))); + $d = ltrim(substr($d, strlen($mx[0]))); + } + if ($d !== "") { + $x["description"] = "<3>" . $d; + } + $xs[] = (object) $x; + } + return $xs; } function ensure_descriptions() {