LCOV - code coverage report
Current view: top level - http/server - openapi.cpp (source / functions) Coverage Total Hit
Test: coverage_filtered.info Lines: 95.4 % 131 125
Test Date: 2026-11-10 10:38:07 Functions: 100.0 % 11 11

            Line data    Source code
       1              : #include "openapi.hpp"
       2              : #include "routing/route_pattern.hpp"
       3              : #include "../../util/logger.hpp"
       4              : #include <algorithm>
       5              : #include <cctype>
       6              : #include <set>
       7              : 
       8              : namespace thinger::http {
       9              : 
      10              : namespace {
      11              : 
      12              :     // Every parameter in the pattern, completed with its documentation
      13          116 :     void add_path_parameters(const route& r, nlohmann::json& parameters) {
      14          173 :         for (const auto& name : r.get_parameters()) {
      15          570 :             nlohmann::json parameter = {{"name", name}, {"in", "path"}, {"required", true}};
      16          228 :             nlohmann::json schema = {{"type", "string"}};
      17              : 
      18           57 :             auto doc = std::find_if(r.get_param_docs().begin(), r.get_param_docs().end(),
      19           36 :                 [&](const route_parameter& p) { return p.in == "path" && p.name == name; });
      20           57 :             if (doc != r.get_param_docs().end()) {
      21           12 :                 if (!doc->description.empty()) parameter["description"] = doc->description;
      22           12 :                 if (!doc->schema.is_null()) schema = doc->schema;
      23              :             }
      24              : 
      25           57 :             auto pattern = r.get_parameter_pattern(name);
      26           57 :             if (!pattern.empty() && schema.is_object() && !schema.contains("pattern")) {
      27           12 :                 schema["pattern"] = "^(?:" + pattern + ")$";
      28              :             }
      29           57 :             parameter["schema"] = std::move(schema);
      30           57 :             parameters.push_back(std::move(parameter));
      31           57 :         }
      32          743 :     }
      33              : 
      34              :     // Query parameters, only as documented
      35          116 :     void add_query_parameters(const route& r, nlohmann::json& parameters) {
      36          140 :         for (const auto& doc : r.get_param_docs()) {
      37           24 :             if (doc.in != "query") continue;
      38          120 :             nlohmann::json parameter = {{"name", doc.name}, {"in", "query"}, {"required", doc.required}};
      39           12 :             if (!doc.description.empty()) parameter["description"] = doc.description;
      40           12 :             parameter["schema"] = doc.schema.is_null() ? nlohmann::json{{"type", "string"}} : doc.schema;
      41           12 :             parameters.push_back(std::move(parameter));
      42           12 :         }
      43          224 :     }
      44              : 
      45              :     // The media types a route takes its body as. Routes reading the body themselves,
      46              :     // documented with a schema or examples: JSON
      47           65 :     std::vector<std::string> body_media_types(const route& r, const body_settings& bodies) {
      48           65 :         std::vector<std::string> media_types;
      49           65 :         if (!r.get_accepted_types().empty()) {
      50           24 :             media_types = accepted_media_types(bodies.decoders, r.get_accepted_types());
      51           41 :         } else if (r.takes_json_body()) {
      52           41 :             media_types = accepted_media_types(bodies.decoders, bodies.accepted);
      53              :         }
      54           65 :         if (media_types.empty()) media_types.emplace_back(default_body_media_type);
      55           65 :         return media_types;
      56            0 :     }
      57              : 
      58              :     // The request body: the route schema and examples, for each media type it accepts (the
      59              :     // schema describes the decoded body, whatever its type). Null if the route has none
      60          116 :     nlohmann::json request_body(const route& r, const body_settings& bodies) {
      61          116 :         const auto& schema = r.get_schema();
      62          116 :         const auto& examples = r.get_examples();
      63          116 :         if (schema.is_null() && examples.empty() && !r.takes_json_body() && r.get_accepted_types().empty()) {
      64           51 :             return nullptr;
      65              :         }
      66              : 
      67          390 :         nlohmann::json media = {{"schema", schema.is_null() ? nlohmann::json::object() : schema}};
      68           65 :         if (examples.size() == 1) {
      69            0 :             media["example"] = examples.front();
      70           65 :         } else if (examples.size() > 1) {
      71           36 :             for (size_t i = 0; i < examples.size(); ++i) {
      72           96 :                 media["examples"]["example" + std::to_string(i + 1)] = {{"value", examples[i]}};
      73              :             }
      74              :         }
      75           65 :         nlohmann::json content = nlohmann::json::object();
      76          202 :         for (const auto& type : body_media_types(r, bodies)) content[type] = media;
      77          455 :         return nlohmann::json{{"required", true}, {"content", std::move(content)}};
      78          876 :     }
      79              : 
      80              :     // OpenAPI requires at least one response
      81          116 :     nlohmann::json responses(const route& r) {
      82          116 :         nlohmann::json result = nlohmann::json::object();
      83          140 :         for (const auto& [status, response] : r.get_responses()) {
      84          144 :             nlohmann::json entry = {{"description", response.description.empty() ? "Response" : response.description}};
      85           24 :             if (!response.schema.is_null() || !response.example.is_null()) {
      86           12 :                 nlohmann::json media = nlohmann::json::object();
      87           12 :                 if (!response.schema.is_null()) media["schema"] = response.schema;
      88           12 :                 if (!response.example.is_null()) media["example"] = response.example;
      89           12 :                 entry["content"]["application/json"] = std::move(media);
      90           12 :             }
      91           24 :             result[std::to_string(status)] = std::move(entry);
      92           24 :         }
      93          532 :         if (result.empty()) result["200"] = {{"description", "Successful response"}};
      94          116 :         return result;
      95          560 :     }
      96              : 
      97              : }
      98              : 
      99           12 : openapi_generator& openapi_generator::server(const std::string& url, const std::string& description) {
     100           48 :     nlohmann::json entry = {{"url", url}};
     101           12 :     if (!description.empty()) entry["description"] = description;
     102           12 :     servers_.push_back(std::move(entry));
     103           12 :     return *this;
     104           60 : }
     105              : 
     106          131 : std::string openapi_generator::to_openapi_path(const std::string& pattern) {
     107              :     // Same parameter syntax as route: :name(regex) and :name
     108          131 :     std::string path;
     109          373 :     for (const auto& token : detail::parse_route_pattern(pattern)) {
     110          242 :         path += token.parameter ? "{" + token.text + "}" : token.text;
     111          131 :     }
     112          131 :     return path;
     113            0 : }
     114              : 
     115          116 : nlohmann::json openapi_generator::build_operation(const route& r) const {
     116          116 :     nlohmann::json operation = nlohmann::json::object();
     117              : 
     118          116 :     if (!r.get_summary().empty()) operation["summary"] = r.get_summary();
     119          116 :     if (!r.get_description().empty()) operation["description"] = r.get_description();
     120          116 :     if (!r.get_tags().empty()) operation["tags"] = r.get_tags();
     121          116 :     if (!r.get_operation_id().empty()) operation["operationId"] = r.get_operation_id();
     122          116 :     if (r.is_deprecated()) operation["deprecated"] = true;
     123              : 
     124          116 :     nlohmann::json parameters = nlohmann::json::array();
     125          116 :     add_path_parameters(r, parameters);
     126          116 :     add_query_parameters(r, parameters);
     127          116 :     if (!parameters.empty()) operation["parameters"] = std::move(parameters);
     128              : 
     129          116 :     auto body = request_body(r, bodies_ ? *bodies_ : body_settings::defaults());
     130          116 :     if (!body.is_null()) operation["requestBody"] = std::move(body);
     131              : 
     132          116 :     operation["responses"] = responses(r);
     133              : 
     134              :     // Limits set on the route or its group, as extensions
     135          116 :     if (const auto& limit = r.get_max_body_size()) operation["x-max-body-size"] = *limit;
     136          116 :     if (const auto& timeout = r.get_timeout(); timeout && timeout->count() > 0) {
     137            3 :         operation["x-timeout-ms"] = timeout->count();
     138              :     }
     139              : 
     140          232 :     return operation;
     141          116 : }
     142              : 
     143           53 : nlohmann::json openapi_generator::generate() const {
     144              :     nlohmann::json document = {
     145              :         {"openapi", "3.1.0"},
     146          106 :         {"info", {{"title", title_}, {"version", version_}}}
     147          795 :     };
     148           53 :     if (!description_.empty()) document["info"]["description"] = description_;
     149           53 :     if (!servers_.empty()) document["servers"] = servers_;
     150              : 
     151           53 :     nlohmann::json paths = nlohmann::json::object();
     152           53 :     std::vector<std::string> tag_names;
     153           53 :     std::set<std::string> seen_tags;
     154              : 
     155          138 :     for (const auto& [http_method, routes] : router_.get_routes()) {
     156           85 :         std::string method_name = get_method(http_method);
     157           85 :         std::transform(method_name.begin(), method_name.end(), method_name.begin(),
     158          287 :                        [](unsigned char c) { return std::tolower(c); });
     159              : 
     160          242 :         for (const auto& r : routes) {
     161          157 :             if (r.is_hidden()) continue;
     162              : 
     163          116 :             auto path = to_openapi_path(r.get_pattern());
     164          116 :             if (paths.contains(path) && paths[path].contains(method_name)) {
     165              :                 // The router serves the first route registered for a pattern
     166            0 :                 LOG_WARNING("OpenAPI: duplicate route {} {}, documenting the first one", method_name, path);
     167            0 :                 continue;
     168            0 :             }
     169              : 
     170          116 :             auto operation = build_operation(r);
     171          122 :             for (const auto& hook : operation_hooks_) hook(r, method_name, operation);
     172          116 :             paths[path][method_name] = std::move(operation);
     173              : 
     174          143 :             for (const auto& tag : r.get_tags()) {
     175           27 :                 if (seen_tags.insert(tag).second) tag_names.push_back(tag);
     176              :             }
     177          116 :         }
     178           85 :     }
     179           53 :     document["paths"] = std::move(paths);
     180              : 
     181           53 :     if (!tag_names.empty()) {
     182           15 :         document["tags"] = nlohmann::json::array();
     183           90 :         for (const auto& tag : tag_names) document["tags"].push_back({{"name", tag}});
     184              :     }
     185              : 
     186           53 :     const auto& components = router_.get_schema_components();
     187           53 :     if (!components.empty()) document["components"]["schemas"] = components;
     188              : 
     189           56 :     for (const auto& hook : document_hooks_) hook(document);
     190          106 :     return document;
     191          855 : }
     192              : 
     193              : } // namespace thinger::http
        

Generated by: LCOV version 2.0-1