diff --git a/README.md b/README.md index 1d80caf10..968105381 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Extensive online help has more on configuring and using HotCRP. See also: * [The HotCRP development manual](./devel/manual/index.md) to learn about advanced configuration, software internals, and developing extensions. -* [The OpenAPI specification](./etc/openapi.json) for API information. +* [The OpenAPI specification](./devel/openapi.json) for API information. Prerequisites diff --git a/batch/apispec.php b/batch/apispec.php index 38e3728be..7bb634e93 100644 --- a/batch/apispec.php +++ b/batch/apispec.php @@ -12,6 +12,8 @@ class APISpec_Batch { public $conf; /** @var Contact */ public $user; + /** @var XtParams */ + private $xtp; /** @var array> */ public $api_map; /** @var object */ @@ -64,11 +66,11 @@ class APISpec_Batch { /** @var string */ private $cur_psubtype; /** @var array */ - private $cur_paramf; + private $cur_fieldf; /** @var array */ - private $cur_params; + private $cur_fields; /** @var array */ - private $cur_paramd; + private $cur_fieldd; const PT_QUERY = 1; const PT_BODY = 2; @@ -85,6 +87,7 @@ class APISpec_Batch { function __construct(Conf $conf, $arg) { $this->conf = $conf; $this->user = $conf->root_user(); + $this->xtp = new XtParams($this->conf, null); $this->api_map = $conf->expanded_api_map(); $this->j = (object) []; @@ -137,9 +140,72 @@ function __construct(Conf $conf, $arg) { $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"]); + $this->sort = !isset($arg["no-sort"]); } + + /** @param mixed $x + * @return bool */ + static function is_empty_object($x) { + return is_object($x) && empty(get_object_vars($x)); + } + + + // ERROR MESSAGES + + /** @return string */ + private function jpath_landmark($jpath) { + $lm = $jpath ? $this->jparser->path_landmark($jpath, false) : null; + return $lm ? "{$lm}: " : ""; + } + + /** @param null|int|string $paramid + * @return string */ + private function cur_landmark($paramid = null) { + $prefix = "{$this->cur_path}.{$this->cur_lmethod}: "; + $jpath = "\$.paths[\"{$this->cur_path}\"].{$this->cur_lmethod}"; + if ($paramid === null) { + // path for operation + } else if ($this->cur_ptype === self::PT_QUERY && is_int($paramid)) { + $jpath .= ".parameters[{$paramid}]"; + } else if ($this->cur_ptype === self::PT_BODY && is_string($paramid)) { + $jpath .= ".requestBody.content[\"{$this->cur_psubtype}\"].schema"; + if ($paramid === "\$required") { + $jpath .= ".required"; + } else { + $jpath .= ".properties[\"{$paramid}\"]"; + } + } else if ($this->cur_ptype === self::PT_RESPONSE && is_string($paramid)) { + $jpath .= ".responses[200].content[\"application/json\"].schema.allOf[{$this->cur_psubtype}]"; + if ($paramid === "\$required") { + $jpath .= ".required"; + } else { + $jpath .= ".properties[\"{$paramid}\"]"; + } + } else { + return ""; + } + return $this->jpath_landmark($jpath); + } + + /** @param int|string $paramid + * @return string */ + private function cur_prefix($paramid = null) { + return $this->cur_landmark($paramid) . "{$this->cur_path}.{$this->cur_lmethod}: "; + } + + /** @return string */ + private function cur_field_description() { + if ($this->cur_ptype === self::PT_QUERY) { + return "parameter"; + } else if ($this->cur_ptype === self::PT_BODY) { + return "body parameter"; + } else { + return "response field"; + } + } + + function _add_description_item($xt) { if (isset($xt->name) && is_string($xt->name)) { $this->description_map[$xt->name][] = $xt; @@ -148,14 +214,15 @@ function _add_description_item($xt) { return false; } - static function parse_description_markdown($s) { + static function parse_description_markdown($s, $landmark) { if (!str_starts_with($s, "#")) { return null; } - $m = preg_split('/^\#\s+([^\n]*?)\s*\n/m', $s, -1, PREG_SPLIT_DELIM_CAPTURE); + $m = preg_split('/^\#\s+([^\n]*?)\s*\n/m', cleannl($s), -1, PREG_SPLIT_DELIM_CAPTURE); $xs = []; + $lineno = 1; for ($i = 1; $i < count($m); $i += 2) { - $x = ["name" => simplify_whitespace($m[$i])]; + $x = ["name" => simplify_whitespace($m[$i]), "landmark" => "{$landmark}:{$lineno}"]; $d = cleannl(ltrim($m[$i + 1])); if (str_starts_with($d, "> ")) { preg_match('/\A(?:^> .*?\n)+/m', $d, $mx); @@ -170,112 +237,18 @@ static function parse_description_markdown($s) { $x["description"] = $d; } $xs[] = (object) $x; + $lineno += 1 + substr_count($m[$i + 1], "\n"); } return $xs; } - /** @return int */ - function run() { - $mj = $this->j; - $mj->openapi = "3.1.0"; - $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) []; - foreach ($this->paths as $name => $pj) { - $pj->__path = $name; - } - - // expand paths - $fns = array_keys($this->api_map); - sort($fns); - foreach ($fns as $fn) { - $aj = []; - foreach ($this->api_map[$fn] as $j) { - if (!isset($j->alias)) - $aj[] = $j; - } - if (!empty($aj)) { - $this->expand_paths($fn); - } - } - - // warn about unreferenced paths - if ($this->batch) { - foreach ($this->paths as $name => $pj) { - if (!isset($this->setj->paths->$name)) { - fwrite(STDERR, "warning: input path {$name} unknown\n"); - } else { - foreach ($pj as $method => $x) { - if ($method !== "__path" - && !isset($this->setj->paths->$name->$method)) { - fwrite(STDERR, "warning: input operation {$method} {$name} unknown\n"); - } - } - } - } - } - - // maybe sort - if ($this->sort || !$this->batch) { - $this->sort(); - } - - // erase unwanted keys - foreach ($this->paths as $pj) { - foreach ($pj as $xj) { - if (!is_object($xj)) { - continue; - } - if (($xj->summary ?? "") === $pj->__path - && !isset($xj->description) - && !isset($xj->operationId)) { - unset($xj->summary); - } - } - unset($pj->__path); - } - foreach ($this->j->tags as $tj) { - unset($tj->summary); - } - - // print - if (($this->output_file ?? "-") === "-") { - $out = STDOUT; - } else { - $out = @fopen(safe_filename($this->output_file), "wb"); - if (!$out) { - throw error_get_last_as_exception("{$this->output_file}: "); - } - } - fwrite($out, json_encode($this->j, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"); - if ($out !== STDOUT) { - fclose($out); - } - return 0; - } - - static function path_first_tag($pj) { - foreach ($pj as $name => $oj) { - if (is_object($oj) && !empty($oj->tags)) { - return $oj->tags[0]; - } - } - return null; - } - - /** @param string $name * @return ?object */ private function find_description($name) { if (!isset($this->description_map[$name])) { return null; } - $xtp = new XtParams($this->conf, null); - return $xtp->search_name($this->description_map, $name); + return $this->xtp->search_name($this->description_map, $name); } /** @param object $xj @@ -312,7 +285,7 @@ private function merge_description($name, $xj) { /** @param string $p * @param int $f * @return array{string,int} */ - static function parse_field_name($p, $f = self::F_DEFAULT) { + static function parse_field_name($p, $f = self::F_DEFAULT | self::F_REQUIRED) { for ($i = 0; $i !== strlen($p); ++$i) { if ($p[$i] === "?") { $f &= ~(self::F_REQUIRED | self::F_DEFAULT); @@ -335,35 +308,35 @@ static function parse_field_name($p, $f = self::F_DEFAULT) { /** @param string $name * @param int $f */ - private function add_parameter($name, $f) { - if (!isset($this->cur_paramf[$name])) { - $this->cur_paramf[$name] = $f; + private function add_field($name, $f) { + if (!isset($this->cur_fieldf[$name])) { + $this->cur_fieldf[$name] = $f; } else if (($f & self::F_DEFAULT) !== 0) { - $this->cur_paramf[$name] |= $f & ~(self::F_DEFAULT | self::F_REQUIRED); + $this->cur_fieldf[$name] |= $f & ~(self::F_DEFAULT | self::F_REQUIRED); } else { - $this->cur_paramf[$name] = ($this->cur_paramf[$name] & ~(self::F_DEFAULT | self::F_REQUIRED)) | $f; + $this->cur_fieldf[$name] = ($this->cur_fieldf[$name] & ~(self::F_DEFAULT | self::F_REQUIRED)) | $f; } } /** @param object $j */ private function parse_basic_parameters($j) { - $this->cur_paramf = []; - $this->cur_params = []; - $this->cur_paramd = []; + $this->cur_fieldf = []; + $this->cur_fields = []; + $this->cur_fieldd = []; if ($j->paper ?? false) { - $this->cur_paramf["p"] = self::F_REQUIRED; + $this->cur_fieldf["p"] = self::F_REQUIRED; } $parameters = $j->parameters ?? []; if (is_string($parameters)) { $parameters = explode(" ", trim($parameters)); } foreach ($parameters as $p) { - list($name, $f) = self::parse_field_name($p, self::F_DEFAULT | self::F_REQUIRED); - $this->add_parameter($name, $f); + list($name, $f) = self::parse_field_name($p); + $this->add_field($name, $f); } if ($j->redirect ?? false) { - $this->add_parameter("redirect", 0); + $this->add_field("redirect", 0); } } @@ -377,9 +350,9 @@ private function expand_paths($fn) { continue; } $this->parse_basic_parameters($uf); - $p = $this->cur_paramf["p"] ?? 0; + $p = $this->cur_fieldf["p"] ?? 0; if (($p & self::F_REQUIRED) !== 0) { - $this->cur_paramf["p"] |= self::F_PATH; + $this->cur_fieldf["p"] |= self::F_PATH; $path = "/{p}/{$fn}"; } else { $path = "/{$fn}"; @@ -417,18 +390,22 @@ private function expand_path_method($path, $lmethod, $uf) { /** @param object $xj * @param object $uf - * @param ?object $dj - * @param string $path */ + * @param ?object $dj */ private function expand_metadata($xj, $uf, $dj) { if ($dj) { $this->merge_description_from($xj, $dj); } - if (isset($uf->tags) && (!isset($xj->tags) || $this->override_tags)) { + 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, "{$this->cur_lmethod} {$this->cur_path}: tags differ, expected " . json_encode($xj->tags) . "\n"); + fwrite(STDERR, $this->cur_prefix() . "tags differ, expected " . json_encode($xj->tags) . "\n"); + } + if (empty($xj->tags)) { + fwrite(STDERR, $this->cur_prefix() . "tags missing\n"); + return; } - foreach ($xj->tags ?? [] as $tag) { + foreach ($xj->tags as $tag) { if (isset($this->setj_tags->$tag)) { continue; } @@ -619,41 +596,41 @@ private function resolve_info($info, $name) { } else if (is_object($info)) { return $info; } else if (!is_string($info) || $info === "") { - fwrite(STDERR, $this->cur_prefix(null) . "bad info for " . $this->cur_field_description() . " `{$name}`\n"); + fwrite(STDERR, $this->cur_prefix() . "bad info for " . $this->cur_field_description() . " `{$name}`\n"); return (object) []; } else if (str_starts_with($info, "[") && str_ends_with($info, "]")) { return (object) ["type" => "array", "items" => $this->resolve_info(substr($info, 1, -1), $name)]; } else if (($s = $this->reference_common_schema($info))) { return $s; } else { - fwrite(STDERR, $this->cur_prefix(null) . "unknown type `{$info}` for " . $this->cur_field_description() . " `{$name}`\n"); + fwrite(STDERR, $this->cur_prefix() . "unknown type `{$info}` for " . $this->cur_field_description() . " `{$name}`\n"); return (object) []; } } - private function parse_description_parameters($params) { + private function parse_description_fields($params, $response) { $pos = 0; while (preg_match('/\G\* (param|parameter|response)[ \t]++([?!+=@:]*+[^\s:]++)[ \t]*+(|[^\s:]++)[ \t]*+(:[^\n]*+(?:\n|\z)(?: [^\n]++\n|[ \t]++\n)*+|\n)(?:[ \t]*+\n)*+/', $params, $m, 0, $pos)) { $pos += strlen($m[0]); - if ($m[1] === "response") { + if (($m[1] === "response") !== $response) { continue; } list($name, $f) = self::parse_field_name($m[2]); - $this->add_parameter($name, $f); + $this->add_field($name, $f); if ($m[3] !== "") { $info = self::resolve_info($m[3], $name); - if (!empty(get_object_vars($info))) { - $this->cur_params[$name] = $info; + if (!self::is_empty_object($info)) { + $this->cur_fields[$name] = $info; } } if (str_starts_with($m[4], ":") && ($d = trim(substr($m[4], 1))) !== "") { - $this->cur_paramd[$name] = $d; + $this->cur_fieldd[$name] = $d; } } } - private function parse_parameter_info($pinfo) { + private function parse_field_info($pinfo) { foreach (get_object_vars($pinfo) as $p => $info) { list($name, $f) = self::parse_field_name($p, self::F_DEFAULT | self::F_REQUIRED); $info = self::resolve_info($info, $name); @@ -661,41 +638,73 @@ private function parse_parameter_info($pinfo) { $f = ($f & ~(self::F_DEFAULT | self::F_REQUIRED)) | ($info->required ? self::F_DEFAULT : 0); unset($info->required); } - $this->add_parameter($name, $f); + $this->add_field($name, $f); if (isset($info->description)) { - $this->cur_paramd[$name] = $info->description; + $this->cur_fieldd[$name] = $info->description; unset($info->description); } if (!empty(get_object_vars($info))) { - $this->cur_params[$name] = $info; + $this->cur_fields[$name] = $info; } } } + /** @param string $name + * @param int $f + * @return ?string */ + private function common_param_name($name, $f, $query_plausible) { + if (!isset(self::$param_schemas[$name])) { + return null; + } + if ($name === "p") { + if (($f & self::F_REQUIRED) === 0) { + return "p.opt"; + } else if (($f & self::F_PATH) !== 0) { + return "p.path"; + } else { + return "p"; + } + } else if ($name === "r") { + return $f & self::F_REQUIRED ? "r" : "r.opt"; + } else if ($name === "c") { + return $f & self::F_REQUIRED ? "c" : "c.opt"; + } else if ($name === "q") { + return $f & self::F_REQUIRED ? "q" : "q.opt"; + } else if ((($name === "redirect" || $name === "forceShow") + && $f === 0) + || (in_array($name, ["t", "qt", "reviewer", "sort", "scoresort"]) + && $query_plausible + && ($f & self::F_REQUIRED) === 0)) { + return $name; + } else { + return null; + } + } + /** @param object $x * @param object $uf * @param ?object $dj */ private function expand_request($x, $uf, $dj) { if ($dj && isset($dj->fields)) { - $this->parse_description_parameters($dj->fields); + $this->parse_description_fields($dj->fields, false); } if (isset($uf->parameter_info)) { - $this->parse_parameter_info($uf->parameter_info); + $this->parse_field_info($uf->parameter_info); } $params = $bprop = $breq = []; - $query_plausible = isset($this->cur_paramf["q"]); + $query_plausible = isset($this->cur_fieldf["q"]); $has_file = false; - foreach ($this->cur_paramf as $name => $f) { + foreach ($this->cur_fieldf as $name => $f) { if ($name === "*" || (($f & self::FM_NONGET) !== 0 && $this->cur_lmethod === "get")) { continue; } if (($f & (self::F_BODY | self::F_FILE)) !== 0) { - $schema = $this->cur_params[$name] ?? self::$param_schemas[$name] ?? null; + $schema = $this->cur_fields[$name] ?? self::$param_schemas[$name] ?? null; $bprop[$name] = $this->resolve_info($schema, $name); - if (isset($this->cur_paramd[$name]) && !isset($bprop[$name]->{"\$ref"})) { - $bprop[$name]->description = $this->cur_paramd[$name]; + if (isset($this->cur_fieldd[$name]) && !isset($bprop[$name]->{"\$ref"})) { + $bprop[$name]->description = $this->cur_fieldd[$name]; } if (($f & self::F_REQUIRED) !== 0) { $breq[] = $name; @@ -705,41 +714,18 @@ private function expand_request($x, $uf, $dj) { } continue; } - if ($name === "p") { - if (($f & self::F_REQUIRED) === 0) { - $pn = "p.opt"; - } else if (($f & self::F_PATH) !== 0) { - $pn = "p.path"; - } else { - $pn = "p"; - } - $params["p"] = $this->reference_common_param($pn); - } else if ($name === "r") { - $pn = $f & self::F_REQUIRED ? "r" : "r.opt"; - $params["r"] = $this->reference_common_param($pn); - } else if ($name === "c") { - $pn = $f & self::F_REQUIRED ? "c" : "c.opt"; - $params["c"] = $this->reference_common_param($pn); - } else if ($name === "redirect" && $f === 0) { - $params["redirect"] = $this->reference_common_param("redirect"); - } else if ($name === "forceShow" && $f === 0) { - $params["forceShow"] = $this->reference_common_param("forceShow"); - } else if ($name === "q") { - $pn = $f & self::F_REQUIRED ? "q" : "q.opt"; - $params["q"] = $this->reference_common_param($pn); - } else if (in_array($name, ["t", "qt", "reviewer", "sort", "scoresort"]) - && $query_plausible - && ($f & self::F_REQUIRED) === 0) { - $params[$name] = $this->reference_common_param($name); + if (($pn = $this->common_param_name($name, $f, $query_plausible)) + && !isset($this->cur_fieldd[$name])) { + $params[$name] = $this->reference_common_param($pn); } else { $params[$name] = (object) [ "name" => $name, "in" => "query", "required" => ($f & self::F_REQUIRED) !== 0, - "schema" => $this->resolve_info($this->cur_params[$name] ?? null, $name) + "schema" => $this->resolve_info($this->cur_fields[$name] ?? null, $name) ]; - if (isset($this->cur_paramd[$name])) { - $params[$name]->description = $this->cur_paramd[$name]; + if (isset($this->cur_fieldd[$name])) { + $params[$name]->description = $this->cur_fieldd[$name]; } } } @@ -761,55 +747,6 @@ private function expand_request($x, $uf, $dj) { } } - private function jpath_landmark($jpath) { - $lm = $jpath ? $this->jparser->path_landmark($jpath, false) : null; - return $lm ? "{$lm}: " : ""; - } - - /** @param int|string $paramid - * @return string */ - private function cur_landmark($paramid) { - $prefix = "{$this->cur_path}.{$this->cur_lmethod}: "; - $jpath = "\$.paths[\"{$this->cur_path}\"].{$this->cur_lmethod}"; - if ($this->cur_ptype === self::PT_QUERY && is_int($paramid)) { - $jpath .= ".parameters[{$paramid}]"; - } else if ($this->cur_ptype === self::PT_BODY && is_string($paramid)) { - $jpath .= ".requestBody.content[\"{$this->cur_psubtype}\"].schema"; - if ($paramid === "\$required") { - $jpath .= ".required"; - } else { - $jpath .= ".properties[\"{$paramid}\"]"; - } - } else if ($this->cur_ptype === self::PT_RESPONSE && is_string($paramid)) { - $jpath .= ".responses[200].content[\"application/json\"].schema.allOf[{$this->cur_psubtype}]"; - if ($paramid === "\$required") { - $jpath .= ".required"; - } else { - $jpath .= ".properties[\"{$paramid}\"]"; - } - } else { - return ""; - } - return $this->jpath_landmark($jpath); - } - - /** @param int|string $paramid - * @return string */ - private function cur_prefix($paramid) { - return $this->cur_landmark($paramid) . "{$this->cur_path}.{$this->cur_lmethod}: "; - } - - /** @return string */ - private function cur_field_description() { - if ($this->cur_ptype === self::PT_QUERY) { - return "parameter"; - } else if ($this->cur_ptype === self::PT_BODY) { - return "body parameter"; - } else { - return "response field"; - } - } - private function apply_parameters($x, $params) { $this->cur_ptype = self::PT_QUERY; @@ -874,7 +811,7 @@ private function apply_required($x, $bprop, $breq, $ignore) { if (isset($bprop[$p]) && !in_array($p, $breq) && !in_array($p, $ignore)) { - fwrite(STDERR, $this->cur_prefix("\$required") . $this->cur_field_description() . "`{$p}` expected optional\n"); + fwrite(STDERR, $this->cur_prefix("\$required") . $this->cur_field_description() . " `{$p}` expected optional\n"); } } foreach ($breq as $p) { @@ -892,33 +829,35 @@ private function apply_required($x, $bprop, $breq, $ignore) { } /** @param object $x - * @param object $uf */ - private function expand_response($x, $uf) { - $bprop = $breq = []; + * @param object $uf + * @param ?object $dj */ + private function expand_response($x, $uf, $dj) { + $this->cur_fieldf = []; + $this->cur_fields = []; + $this->cur_fieldd = []; + $response = $uf->response ?? []; if (is_string($response)) { $response = explode(" ", trim($response)); } foreach ($response as $p) { - $f = self::F_REQUIRED; - for ($i = 0; $i !== strlen($p); ++$i) { - if ($p[$i] === "?") { - $f &= ~self::F_REQUIRED; - } else if ($p[$i] === "+") { - $f |= self::F_POST; - } else { - break; - } - } - if (($f & self::FM_NONGET) !== 0 && $this->cur_lmethod === "get") { - continue; - } - $name = substr($p, $i); - if ($name === "*") { + list($name, $f) = self::parse_field_name($p); + $this->cur_fieldf[$name] = $f; + } + if ($dj && isset($dj->fields)) { + $this->parse_description_fields($dj->fields, true); + } + if (isset($uf->response_info)) { + $this->parse_field_info($uf->response_info); + } + + $bprop = $breq = []; + foreach ($this->cur_fieldf as $name => $f) { + if ($name === "*" + || (($f & self::FM_NONGET) !== 0 && $this->cur_lmethod === "get")) { continue; } - $ps = $uf->response_info->$name ?? null; - $bprop[$name] = $this->resolve_info($uf->response_info->$name ?? null, $name); + $bprop[$name] = $this->resolve_info($this->cur_fields[$name] ?? null, $name); if (($f & self::F_REQUIRED) !== 0) { $breq[] = $name; } @@ -1015,6 +954,10 @@ private function apply_response($x, $bprop, $breq) { || $this->combine_fields($k, $v, $respprop->{$k}, $k)) { $respprop->$k = $v; } + if (isset($this->cur_fieldd[$k]) + && ($this->override_description || ($respprop->$k->description ?? "") === "")) { + $respprop->$k->description = $this->cur_fieldd[$k]; + } } if (!empty(get_object_vars($respprop))) { $respb->properties = $respprop; @@ -1048,9 +991,10 @@ private function combine_fields($name, $npj, $xpj, $paramid) { } } else { foreach ((array) $npj as $k => $v) { - if (!isset($xpj->$k)) { + if (!isset($xpj->$k) + || self::is_empty_object($xpj->$k)) { $xpj->$k = $v; - } else if (is_object($v) && empty(get_object_vars($v))) { + } else if (self::is_empty_object($v)) { continue; } else if (is_scalar($v) ? $xpj->$k !== $v : json_encode($xpj->$k) !== json_encode($v)) { fwrite(STDERR, $this->cur_prefix($paramid) . "{$paramdesc} `{$name}` {$k} differs\n input " . json_encode($xpj->$k) . ", expected " . json_encode($v) . "\n"); @@ -1073,6 +1017,9 @@ static private function allOf_object_position($j) { return $found; } + + // SORTING + private function sort() { $this->tag_order = []; foreach ($this->j->tags ?? [] as $i => $x) { @@ -1095,6 +1042,15 @@ private function sort() { $this->j->paths = (object) $paths; } + static function path_first_tag($pj) { + foreach ($pj as $name => $oj) { + if (is_object($oj) && !empty($oj->tags)) { + return $oj->tags[0]; + } + } + return null; + } + function compare_paths($a, $b) { $atag = self::path_first_tag($a); $btag = self::path_first_tag($b); @@ -1124,6 +1080,100 @@ function compare_paths($a, $b) { return strcmp($an, $bn); } + + // RUNNING + + /** @return int */ + function run() { + $mj = $this->j; + $mj->openapi = "3.1.0"; + $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) []; + foreach ($this->paths as $name => $pj) { + $pj->__path = $name; + } + + // expand paths + $fns = array_keys($this->api_map); + sort($fns); + foreach ($fns as $fn) { + $aj = []; + foreach ($this->api_map[$fn] as $j) { + if (!isset($j->alias)) + $aj[] = $j; + } + if (!empty($aj)) { + $this->expand_paths($fn); + } + } + + // warn about unreferenced paths + if ($this->batch) { + foreach ($this->paths as $name => $pj) { + if (!isset($this->setj->paths->$name)) { + fwrite(STDERR, $this->jpath_landmark("\$.paths[\"{$name}\"]") . "input path {$name} not specified\n"); + } else { + foreach ($pj as $lmethod => $x) { + if ($lmethod !== "__path" + && !isset($this->setj->paths->$name->$lmethod)) { + fwrite(STDERR, $this->jpath_landmark("\$.paths[\"{$name}\"].{$lmethod}") . "input operation {$lmethod} {$name} not specified\n"); + } + } + } + } + } + foreach ($this->description_map as $name => $djs) { + if (preg_match('/\A(get|post)\s+(\S+)\z/', $name, $m) + && !isset($this->paths->{$m[2]}->{$m[1]}) + && ($dj = $this->find_description($name))) { + fwrite(STDERR, "{$dj->landmark}: description path {$m[1]}.{$m[2]} not specified\n"); + } + } + + // maybe sort + if ($this->sort || !$this->batch) { + $this->sort(); + } + + // erase unwanted keys + foreach ($this->paths as $pj) { + foreach ($pj as $xj) { + if (!is_object($xj)) { + continue; + } + if (($xj->summary ?? "") === $pj->__path + && !isset($xj->description) + && !isset($xj->operationId)) { + unset($xj->summary); + } + } + unset($pj->__path); + } + foreach ($this->j->tags as $tj) { + unset($tj->summary); + } + + // print + if (($this->output_file ?? "-") === "-") { + $out = STDOUT; + } else { + $out = @fopen(safe_filename($this->output_file), "wb"); + if (!$out) { + throw error_get_last_as_exception("{$this->output_file}: "); + } + } + fwrite($out, json_encode($this->j, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n"); + if ($out !== STDOUT) { + fclose($out); + } + return 0; + } + /** @return array{Conf,array} */ static function parse_args($argv) { $arg = (new Getopt)->long( @@ -1139,7 +1189,8 @@ static function parse_args($argv) { "override-tags", "override-schema", "no-override-description", - "sort", + "no-sort", + "sort !", "o:,output: =FILE Write specification to FILE" )->description("Generate an OpenAPI specification. Usage: php batch/apispec.php") diff --git a/devel/apidoc/comments.md b/devel/apidoc/comments.md index 523317bee..b48662704 100644 --- a/devel/apidoc/comments.md +++ b/devel/apidoc/comments.md @@ -29,6 +29,8 @@ response. Otherwise, an error response is returned. If `c` is omitted, all viewable comments are returned in a `comments` list. +* param content boolean: False omits comment content from response + # post /{p}/comment @@ -55,3 +57,16 @@ To upload a single new attachment: To upload multiple attachments, number them sequentially (`attachment:2`, `attachment:3`, and so forth). To delete an existing attachment, supply its `docid` as an `attachment:N` parameter, and set `attachment:N:delete` to 1. + +* param override boolean +* param delete boolean +* param text string +* param tags string +* param topic comment_topic +* param visibility comment_visibility +* param response string +* param ready boolean +* param draft boolean +* param blind boolean +* param by_author boolean +* param review_token string diff --git a/devel/apidoc/index.md b/devel/apidoc/index.md index 1308357e9..38533e219 100644 --- a/devel/apidoc/index.md +++ b/devel/apidoc/index.md @@ -14,16 +14,15 @@ open a [GitHub issue](https://github.com/kohler/hotcrp/issues). HotCRP reads parameters using [form encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST), either in query strings or in the request body. Complex requests either use -structured keys, such as `named_search/1/q`, or, occasionally, JSON encoding. -`multipart/form-data` is used for requests that include file data. +structured keys, such as `named_search/1/q`, or, occasionally, parameters +encoded as JSON. Use `multipart/form-data` for requests that include uploaded +files. The `p` parameter, which defines a submission ID, can appear either in the query string or immediately following `api/` in the query path. -`api/comment?p=1` and `api/1/comment` are the same API call. +`api/comment?p=1` and `api/1/comment` are the same API call. `p` is generally +a decimal number greater than 0. -Responses are formatted as JSON. - - -# schema message - -Fart fart +Responses are formatted as JSON. Every response has an `ok` property; `ok` is +`true` if the request succeeded and `false` otherwise. Messages about the +request, if any, are expressed in a `message_list` property. diff --git a/devel/apidoc/redocly.yaml b/devel/apidoc/redocly.yaml index 9d76cd1fd..355536f02 100644 --- a/devel/apidoc/redocly.yaml +++ b/devel/apidoc/redocly.yaml @@ -1,6 +1,6 @@ apis: core@v1: - root: ../../etc/openapi.json + root: ../openapi.json rules: operation-4xx-response: off operation-operationId: off @@ -14,7 +14,6 @@ extends: theme: openapi: - schemaExpansionLevel: 3 theme: sidebar: backgroundColor: 'ivory' diff --git a/devel/apidoc/submissions.md b/devel/apidoc/submissions.md index b3e3b49f8..d091532da 100644 --- a/devel/apidoc/submissions.md +++ b/devel/apidoc/submissions.md @@ -5,64 +5,58 @@ These endpoints query and modify HotCRP submissions. # get /paper -> Retrieve submission(s) +> Retrieve submission -Use this endpoint to retrieve JSON-formatted information about submissions. +This endpoint retrieves JSON-formatted information about a submission. All +visible information about submission fields, tags, and overall status are +returned in as the response’s `paper` property. Error messages—for instance, +about permission errors or nonexistent submissions—are returned in +`message_list`. -Provide either the `p` parameter or the `q` parameter. The `p` parameter -should be a submission ID; the server will return information about that -single submission in the `paper` response field. Otherwise, the `q` parameter -should be a search query (other search parameters `t`, `qt`, etc. are also -accepted); the server will return information about all matching submissions -in the `papers` response field, which is an array of paper objects. +The returned `paper` property is a submission object. Submission objects are +formatted based on the submission form. Every paper object has an `object` +property set to `"paper"`, a `pid`, and a `status`. Other properties are +provided based on which submission fields exist and whether the accessing user +can see them. -Error messages—for instance, about permission errors—are returned in the -`message_list` component as usual. - -Paper object fields depend on the submission form. Every paper has an `object` -member, a `pid`, and a `status`. Other fields are provided depending on -whether they exist and whether the accessing user can see them. +* param forceShow boolean: False to not override administrator conflict # post /paper -> Create or modify submission(s) - -### Single submission +> Create or modify submission -A request with a `p` parameter (as a path parameter `/{p}/paper` or a query -parameter) modifies the submission with that ID. The special ID `new` can be -used to create a submission. +This endpoint modifies a specified submission. The `p` parameter determines +the submission ID. Setting `p=new` will create a new submission; the response +will contain the chosen submission ID. Modifications are specified using a JSON object. There are three ways to provide that JSON, depending on the content-type of the request: 1. As a request body with content-type `application/json`. -2. As a file named `data.json` in an uploaded ZIP archive, with content-type - `application/zip`. -3. As a parameter named `json` (body type - `application/x-www-form-urlencoded` or `multipart/form-data`). - -The JSON upload must be formatted as an object. +2. As a file named `data.json` in a ZIP archive. The request body has + content-type `application/zip`. +3. As a parameter named `json` in a normal `application/x-www-form-urlencoded` + or `multipart/form-data` body. -ZIP and form uploads also support document upload. A document is referenced -via `content_file` fields in the JSON. +However it is provided, the JSON must define an object interpretable as a +submission (or a subset of a submission). The properties of this object define +the modifications to be applied to the submission. -### Multiple submissions +The `p` parameter is optional; if unset, HotCRP uses the `pid` from the +supplied JSON. If the `p` parameter and the JSON `pid` property are both +present, then they must match. -A request with no `p` parameter can create or modify any number of -submissions. Upload types are the same as for single submissions, but the JSON -upload is defined as an array of objects. These objects are processed in turn. +To test a modification, supply a `dry_run=1` parameter. This will test the +uploaded JSON but make no changes to the database. -Currently, multiple-submission upload is only allowed for administrators. -### ZIP uploads +### ZIP and form uploads A ZIP upload should contain a file named `data.json` (`PREFIX-data.json` is -also acceptable). This file’s content is parsed as JSON and treated a -submission object (or array of submission objects). Attachment fields in the -JSON content can refer to other files in the ZIP. For instance, this shell -session might upload a submission with content `paper.pdf`: +also acceptable). This file’s content is parsed as JSON. Attachment fields in +the JSON can refer to other files in the ZIP. For instance, this shell session +uploads a new submission with content `paper.pdf`: ``` $ cat data.json @@ -78,10 +72,104 @@ $ zip upload.zip data.json paper.pdf $ curl -H "Authorization: bearer hct_XXX" --data-binary @upload.zip -H "Content-Type: application/zip" SITEURL/api/paper ``` +This shell session does the same, but using `multipart/form-data`. + +``` +$ curl -H "Authorization: bearer hct_XXX" -F "json= Retrieve multiple submissions + +This endpoint retrieves JSON-formatted information about multiple submissions +based on a search. The search is specified in the `q` parameter; all matching +visible papers are returned. Other search parameters (`t`, `qt`) are accepted +too. The response property `papers` is an array of matching submission objects. + +Since searches silently filter out non-viewable submissions, `/papers?q=1010` +and `/paper?p=1010` can return different error messages. The `/paper` request +might return an error like `Submission #1010 does not exist` or `You aren’t +allowed to view submission #10110`, whereas the `/papers` request will return +no errors. To obtain warnings for missing submissions that were explicitly +listed in a query, supply a `warn_missing=1` parameter. + +* param warn_missing boolean: Get warnings for missing submissions + + +# post /papers + +> Create or modify multiple submissions + +This administrator-only endpoint modifies multiple submissions at once. Its +request formats are similar to that of `POST /{p}/paper`: it can accept a +JSON, ZIP, or form-encoded request body with a `json` parameter, and ZIP and +form-encoded requests can also include attached files. + +The JSON provided for `/papers` should be an *array* of JSON objects. Response +properties `papers`, `change_lists`, and `valid` are arrays with the same +number of elements as the input JSON; component *i* of each response property +contains the result for the *i*th submission object in the input JSON. + +Alternately, you can provide a `q` search query parameter and a *single* JSON +object. The JSON object must not have a `pid` property. The JSON modification +will be applied to all papers returned by the `q` search query. + +The response `message_list` contains messages relating to all modified +submissions. To filter out the messages for a single submission, use the +messages’ `landmark` properties. `landmark` is set to the integer index of the +relevant submission in the input JSON. + * param dry_run boolean: True checks input for errors, but does not save changes * param disable_users boolean: True disables any newly-created users (administrators only) * param add_topics boolean: True automatically adds topics from input papers (administrators only) * param notify boolean: False does not notify contacts of changes (administrators only) +* param json string +* param ?q search_string: Search query for match requests +* param ?t search_collection: Collection to search; defaults to `viewable` +* response ?dry_run boolean: True for `dry_run` requests +* response ?papers [paper]: List of JSON versions of modified papers +* response ?+change_lists [[string]]: List of lists of changed fields +* response ?+valid [boolean]: List of validity checks diff --git a/etc/openapi.json b/devel/openapi.json similarity index 92% rename from etc/openapi.json rename to devel/openapi.json index 01545b63d..3e7c459ce 100644 --- a/etc/openapi.json +++ b/devel/openapi.json @@ -3,7 +3,7 @@ "info": { "title": "HotCRP", "version": "0.1", - "description": "[HotCRP](https://github.com/kohler/hotcrp) is conference review software. It\nis open source; a supported version runs on [hotcrp.com](https://hotcrp.com/).\n\nWe welcome [pull requests](https://github.com/kohler/hotcrp/pulls) that fill\nout this documentation. To request documentation for an API method, please\nopen a [GitHub issue](https://github.com/kohler/hotcrp/issues).\n\n## Basics\n\nHotCRP reads parameters using [form\nencoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST),\neither in query strings or in the request body. Complex requests either use\nstructured keys, such as `named_search/1/q`, or, occasionally, JSON encoding.\n`multipart/form-data` is used for requests that include file data.\n\nThe `p` parameter, which defines a submission ID, can appear either in the\nquery string or immediately following `api/` in the query path.\n`api/comment?p=1` and `api/1/comment` are the same API call.\n\nResponses are formatted as JSON.\n\n\n", + "description": "[HotCRP](https://github.com/kohler/hotcrp) is conference review software. It\nis open source; a supported version runs on [hotcrp.com](https://hotcrp.com/).\n\nWe welcome [pull requests](https://github.com/kohler/hotcrp/pulls) that fill\nout this documentation. To request documentation for an API method, please\nopen a [GitHub issue](https://github.com/kohler/hotcrp/issues).\n\n## Basics\n\nHotCRP reads parameters using [form\nencoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST),\neither in query strings or in the request body. Complex requests either use\nstructured keys, such as `named_search/1/q`, or, occasionally, parameters\nencoded as JSON. Use `multipart/form-data` for requests that include uploaded\nfiles.\n\nThe `p` parameter, which defines a submission ID, can appear either in the\nquery string or immediately following `api/` in the query path.\n`api/comment?p=1` and `api/1/comment` are the same API call. `p` is generally\na decimal number greater than 0.\n\nResponses are formatted as JSON. Every response has an `ok` property; `ok` is\n`true` if the request succeeded and `false` otherwise. Messages about the\nrequest, if any, are expressed in a `message_list` property.\n", "summary": "HotCRP conference management software API" }, "paths": { @@ -12,8 +12,23 @@ "tags": [ "Submissions" ], + "parameters": [ + { + "$ref": "#/components/parameters/p.opt" + }, + { + "name": "forceShow", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "False to not override administrator conflict" + } + ], "responses": { "200": { + "description": "", "content": { "application/json": { "schema": { @@ -24,22 +39,14 @@ { "type": "object", "properties": { - "paper": { - "$ref": "#/components/schemas/paper" - }, - "papers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/paper" - } - } + "paper": {}, + "dry_run": {} } } ] } } - }, - "description": "" + } }, "default": { "description": "", @@ -52,39 +59,65 @@ } } }, + "summary": "Retrieve submission", + "description": "This endpoint retrieves JSON-formatted information about a submission. All\nvisible information about submission fields, tags, and overall status are\nreturned in as the response’s `paper` property. Error messages—for instance,\nabout permission errors or nonexistent submissions—are returned in\n`message_list`.\n\nThe returned `paper` property is a submission object. Submission objects are\nformatted based on the submission form. Every paper object has an `object`\nproperty set to `\"paper\"`, a `pid`, and a `status`. Other properties are\nprovided based on which submission fields exist and whether the accessing user\ncan see them.\n" + }, + "post": { + "tags": [ + "Submissions" + ], "parameters": [ { "$ref": "#/components/parameters/p.opt" }, { - "$ref": "#/components/parameters/q.opt" - }, - { - "$ref": "#/components/parameters/t" + "name": "dry_run", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "True checks input for errors, but does not save changes" }, { - "$ref": "#/components/parameters/qt" + "name": "disable_users", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "True disables any newly-created users (administrators only)" }, { - "$ref": "#/components/parameters/sort" + "name": "add_topics", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "True automatically adds topics from input papers (administrators only)" }, { - "$ref": "#/components/parameters/scoresort" + "name": "notify", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + }, + "description": "False does not notify contacts of changes (administrators only)" }, { - "$ref": "#/components/parameters/reviewer" + "$ref": "#/components/parameters/forceShow" }, { - "$ref": "#/components/parameters/forceShow" + "name": "json", + "in": "query", + "required": true, + "schema": { + "type": "string" + } } ], - "summary": "Retrieve submission(s)", - "description": "Use this endpoint to retrieve JSON-formatted information about submissions.\n\nProvide either the `p` parameter or the `q` parameter. The `p` parameter\nshould be a submission ID; the server will return information about that\nsingle submission in the `paper` response field. Otherwise, the `q` parameter\nshould be a search query (other search parameters `t`, `qt`, etc. are also\naccepted); the server will return information about all matching submissions\nin the `papers` response field, which is an array of paper objects.\n\nError messages—for instance, about permission errors—are returned in the\n`message_list` component as usual.\n\nPaper object fields depend on the submission form. Every paper has an `object`\nmember, a `pid`, and a `status`. Other fields are provided depending on\nwhether they exist and whether the accessing user can see them.\n\n\n" - }, - "post": { - "tags": [ - "Submissions" - ], "responses": { "200": { "description": "", @@ -99,34 +132,23 @@ "type": "object", "properties": { "paper": { - "$ref": "#/components/schemas/paper" + "$ref": "#/components/schemas/paper", + "description": "JSON version of modified paper" }, - "papers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/paper" - } + "dry_run": { + "type": "boolean", + "description": "True for `dry_run` requests" }, "change_list": { "type": "array", "items": { "type": "string" - } - }, - "change_lists": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "string" - } - } + }, + "description": "List of changed fields" }, "valid": { - "type": "array", - "items": { - "type": "boolean" - } + "type": "boolean", + "description": "True if the modification was valid" } } } @@ -146,10 +168,84 @@ } } }, + "summary": "Create or modify submission", + "description": "This endpoint modifies a specified submission. The `p` parameter determines\nthe submission ID. Setting `p=new` will create a new submission; the response\nwill contain the chosen submission ID.\n\nModifications are specified using a JSON object. There are three ways to\nprovide that JSON, depending on the content-type of the request:\n\n1. As a request body with content-type `application/json`.\n2. As a file named `data.json` in a ZIP archive. The request body has\n content-type `application/zip`.\n3. As a parameter named `json` in a normal `application/x-www-form-urlencoded`\n or `multipart/form-data` body.\n\nHowever it is provided, the JSON must define an object interpretable as a\nsubmission (or a subset of a submission). The properties of this object define\nthe modifications to be applied to the submission.\n\nThe `p` parameter is optional; if unset, HotCRP uses the `pid` from the\nsupplied JSON. If the `p` parameter and the JSON `pid` property are both\npresent, then they must match.\n\nTo test a modification, supply a `dry_run=1` parameter. This will test the\nuploaded JSON but make no changes to the database.\n\n\n### ZIP and form uploads\n\nA ZIP upload should contain a file named `data.json` (`PREFIX-data.json` is\nalso acceptable). This file’s content is parsed as JSON. Attachment fields in\nthe JSON can refer to other files in the ZIP. For instance, this shell session\nuploads a new submission with content `paper.pdf`:\n\n```\n$ cat data.json\n{\n\t\"object\": \"paper\",\n\t\"pid\": \"new\",\n\t\"title\": \"Aught: A Methodology for the Visualization of Scheme\",\n\t\"authors\": [{\"name\": \"Nevaeh Gomez\", \"email\": \"ngomez@example.edu\"}],\n\t\"submission\": {\"content_file\": \"paper.pdf\"},\n\t\"status\": \"submitted\"\n}\n$ zip upload.zip data.json paper.pdf\n$ curl -H \"Authorization: bearer hct_XXX\" --data-binary @upload.zip -H \"Content-Type: application/zip\" SITEURL/api/paper\n```\n\nThis shell session does the same, but using `multipart/form-data`.\n\n```\n$ curl -H \"Authorization: bearer hct_XXX\" -F \"json=response_deprecated ?? []; - if (is_string($response_deprecated)) { - $response_deprecated = explode(" ", trim($response_deprecated)); - } $nmandatory = count($response); - if (!empty($response_deprecated)) { - array_push($response, ...$response_deprecated); - } $known = []; $has_suffix = false; foreach ($response as $ri => $p) { - $t = $ri < $nmandatory ? self::F_REQUIRED : self::F_DEPRECATED; + $f = self::F_REQUIRED; for ($i = 0; $i !== strlen($p); ++$i) { if ($p[$i] === "?") { - $t &= ~self::F_REQUIRED; + $f &= ~self::F_REQUIRED; + } else if ($p[$i] === "!") { + $f |= self::F_REQUIRED; + } else if ($p[$i] === "<") { + $f |= self::F_DEPRECATED; } else if ($p[$i] === ":") { - $t |= self::F_SUFFIX; + $f |= self::F_SUFFIX; $has_suffix = true; } else if ($p[$i] === "*") { - $t &= ~self::F_REQUIRED; + $f &= ~self::F_REQUIRED; break; } else { break; } } $n = substr($p, $i); - $known[$n] = $t; + $known[$n] = $f; } foreach (array_keys($jr->content) as $n) { if (($t = self::lookup_type($n, $known, $has_suffix)) === null) { @@ -176,7 +175,7 @@ static function unparse_param_type($n, $t) { if (($t & self::F_FILE) !== 0) { return "file param"; } else if (($t & self::F_BODY) !== 0) { - return "post param"; + return "body param"; } else { return "query param"; }