diff --git a/docs/api/apiv3/endpoints/groups.apib b/docs/api/apiv3/endpoints/groups.apib index a7efa985ada..e48a54264c7 100644 --- a/docs/api/apiv3/endpoints/groups.apib +++ b/docs/api/apiv3/endpoints/groups.apib @@ -260,7 +260,7 @@ Updates the given group by applying the attributes provided in the body. } } -## Delete group [/api/v3/group/{id}] +## Delete group [/api/v3/groups/{id}] ## Delete group [DELETE] diff --git a/frontend/src/app/core/setup/global-dynamic-components.const.ts b/frontend/src/app/core/setup/global-dynamic-components.const.ts index a632899ecbe..28b09c734fc 100644 --- a/frontend/src/app/core/setup/global-dynamic-components.const.ts +++ b/frontend/src/app/core/setup/global-dynamic-components.const.ts @@ -135,6 +135,7 @@ import { slideToggleSelector } from "core-app/shared/components/slide-toggle/slide-toggle.component"; import { BackupComponent, backupSelector } from "core-app/core/setup/globals/components/admin/backup.component"; +import { DocsComponent, docsSelector } from "core-app/core/setup/globals/components/docs/docs.component"; import { EnterpriseBaseComponent, enterpriseBaseSelector, @@ -220,7 +221,8 @@ export const globalDynamicComponents:OptionalBootstrapDefinition[] = [ { selector: editableQueryPropsSelector, cls: EditableQueryPropsComponent }, { selector: slideToggleSelector, cls: SlideToggleComponent }, { selector: backupSelector, cls: BackupComponent }, - { selector: opInAppNotificationBellSelector, cls: InAppNotificationBellComponent } + { selector: opInAppNotificationBellSelector, cls: InAppNotificationBellComponent }, + { selector: docsSelector, cls: DocsComponent } ]; diff --git a/frontend/src/app/shared/components/docs/docs.component.html b/frontend/src/app/core/setup/globals/components/docs/docs.component.html similarity index 100% rename from frontend/src/app/shared/components/docs/docs.component.html rename to frontend/src/app/core/setup/globals/components/docs/docs.component.html diff --git a/frontend/src/app/shared/components/docs/docs.component.sass b/frontend/src/app/core/setup/globals/components/docs/docs.component.sass similarity index 100% rename from frontend/src/app/shared/components/docs/docs.component.sass rename to frontend/src/app/core/setup/globals/components/docs/docs.component.sass diff --git a/frontend/src/app/shared/components/docs/docs.component.ts b/frontend/src/app/core/setup/globals/components/docs/docs.component.ts similarity index 100% rename from frontend/src/app/shared/components/docs/docs.component.ts rename to frontend/src/app/core/setup/globals/components/docs/docs.component.ts diff --git a/lib/api/open_api.rb b/lib/api/open_api.rb index 6b51255a888..9c9de3c1b01 100644 --- a/lib/api/open_api.rb +++ b/lib/api/open_api.rb @@ -1,7 +1,63 @@ module API module OpenAPI - def self.spec(version: :stable) - API::OpenAPI::BlueprintImport.convert version: version + extend self + + def spec(version: :stable) + spec_path = Rails.application.root.join("docs/api/apiv3/openapi-spec.yml") + + if spec_path.exist? + assemble_spec spec_path + else + API::OpenAPI::BlueprintImport.convert version: version, single_file: true + end + end + + def assemble_spec(file_path) + spec = YAML.load File.read(file_path.to_s) + + substitute_refs(spec, path: file_path.parent, root_path: file_path.parent) + end + + def substitute_refs(spec, path:, root_path:, root_spec: spec) + if spec.is_a?(Hash) + if spec.size == 1 && spec.keys.first == "$ref" + ref_path = path.join spec.values.first + ref_value = YAML.load File.read(ref_path.to_s) + + resolve_refs ref_value, path: ref_path.parent, root_path: root_path, root_spec: root_spec + else + spec.map { |k, v| [k, substitute_refs(v, path: path, root_path: root_path, root_spec: root_spec)] }.to_h + end + elsif spec.is_a?(Array) + spec.map { |s| substitute_refs s, path: path, root_path: root_path, root_spec: root_spec } + else + spec + end + end + + def resolve_refs(spec, path:, root_path:, root_spec:) + if spec.is_a?(Hash) + if spec.size == 1 && spec.keys.first == "$ref" + ref_path = spec.values.first + + if ref_path.start_with?(".") + schema_file = path.join(ref_path).to_s.sub(root_path.to_s, ".") + schema_path = path.join(ref_path).parent.to_s.sub(root_path.to_s, "").split("/").drop(1) + schema_name = root_spec.dig(*schema_path).find { |k, v| v["$ref"] == schema_file }.first + schema_ref = path.join(ref_path).parent.join(schema_name).to_s.sub(root_path.to_s, "#") + + { spec.keys.first => schema_ref } + else + spec + end + else + spec.map { |k, v| [k, resolve_refs(v, path: path, root_path: root_path, root_spec: root_spec)] }.to_h + end + elsif spec.is_a?(Array) + spec.map { |v| resolve_refs(v, path: path, root_path: root_path, root_spec: root_spec) } + else + spec + end end end end diff --git a/lib/api/open_api/blueprint_import.rb b/lib/api/open_api/blueprint_import.rb index fb9561871c7..4978d0fb3aa 100644 --- a/lib/api/open_api/blueprint_import.rb +++ b/lib/api/open_api/blueprint_import.rb @@ -21,7 +21,7 @@ module API @include_directive_regex ||= /\<\!\-\-\s*include\((.*)\)\s*\-\-\>/ end - def convert(version: :stable) + def convert(version: :stable, single_file: false) input_file = Rails.application.root.join("docs/api/apiv3-doc-#{version}.apib") md_file = Tempfile.new("apibp.md").path assemble_file input_path: input_file, output_path: md_file @@ -31,10 +31,119 @@ module API add_security! spec amend_schemas! spec, apibp: File.read(md_file) + if !single_file + split_up_schemas! spec + split_up_paths! spec + split_up_tags! spec + end + spec ensure FileUtils.rm_f md_file if File.exist? md_file end + + def split_up_schemas!(spec) + file_path = Rails.application.root.join "docs/api/apiv3/components/schemas" + + FileUtils.mkdir_p file_path.to_s + + new_schemas = spec["components"]["schemas"].map do |name, content| + identifier = name.underscore + file_name = "#{identifier}.yml" + + File.open(file_path.join(file_name), "w") do |f| + f.write "# Schema: #{name}\n" + f.write content.to_yaml + end + + [name, { "$ref" => "./components/schemas/#{file_name}"}] + end + + spec["components"]["schemas"] = new_schemas.to_h + end + + def split_up_tags!(spec) + file_path = Rails.application.root.join "docs/api/apiv3/tags" + + FileUtils.mkdir_p file_path.to_s + + new_tags = spec["tags"].map do |value| + identifier = value["name"].downcase.gsub("&", "and").gsub(" ", "_") + file_name = "#{identifier}.yml" + + File.open(file_path.join(file_name), "w") do |f| + f.write value.to_yaml + end + + { "$ref" => "./tags/#{file_name}"} + end + + spec["tags"] = new_tags + end + + def split_up_paths!(spec) + file_path = Rails.application.root.join "docs/api/apiv3/paths" + + FileUtils.mkdir_p file_path.to_s + + new_paths = spec["paths"].map do |path, content| + segments = path.sub("/api/v3", "").split("/").reject(&:blank?) + + (0..(segments.size - 1)).each do |i| + if i > 0 && segments[i].end_with?("id}") + before = segments[i - 1] + after = before.singularize + + # certain words like 'news' can't be singularized + if before == after + segments[i - 1] = "#{before}_item" + else + segments[i - 1] = after + end + end + end + + identifier = segments.reject { |s| s.end_with?("id}") }.join("_").presence || "root" + file_name = "#{identifier}.yml" + + File.open(file_path.join(file_name), "w") do |f| + f.write "# #{path}\n" + f.write fix_operation_ids!(fix_references!(content.dup, context: spec)).to_yaml + end + + [path, { "$ref" => "./paths/#{file_name}"}] + end + + raise "Splitting up into paths failed! Expected same number of paths. " unless new_paths.size == spec["paths"].size + + spec["paths"] = new_paths.to_h + end + + def fix_operation_ids!(spec) + spec.each do |key, value| + if value.is_a? Hash + fix_operation_ids! value + elsif key == "operationId" + spec[key] = spec[key].gsub " ", "_" + end + end + + spec + end + + def fix_references!(spec, context:) + spec.each do |key, value| + if value.is_a? Hash + fix_references! value, context: context + elsif value.is_a? Array + spec[key] = value.map { |v| v.is_a?(Hash) ? fix_references!(v.dup, context: context) : v } + elsif key == "$ref" && value.start_with?("#/components") + spec[key] = '.' + context.dig(*(value.split("/").drop(1) + ['$ref'])) + end + end + + spec + end def add_security!(spec) spec["components"]["securitySchemes"] = { @@ -106,7 +215,6 @@ module API "href" => { "type" => "string", "nullable" => true, - "format" => "uri", "description" => "URL to the referenced resource (might be relative)" }, "title" => { @@ -273,7 +381,7 @@ module API link = {} value = { - "allOf" => [{ "$ref" => "#/components/schemas/Link" }, link] + "allOf" => [{ "$ref" => "./link.yml" }, link] } set_description! link, row, desc_index @@ -351,6 +459,17 @@ module API add_conditions! value, row, cond_index + if type == "Formattable" + value.delete "type" + + value = { + "allOf" => [ + { "$ref" => "./formattable.yml" }, + value + ] + } + end + [name, value] end diff --git a/lib/tasks/api.rake b/lib/tasks/api.rake index 1f046fbf52b..1b935d4b260 100644 --- a/lib/tasks/api.rake +++ b/lib/tasks/api.rake @@ -49,9 +49,19 @@ namespace :api do desc 'Saves the API spec (OAS3.0) to ./docs/api/openproject-apiv3-.yml' task :update_spec, [:branch] => [:environment] do |task, args| branch = (args[:branch] || "stable").to_sym - spec = API::OpenAPI::BlueprintImport.convert version: branch + spec = API::OpenAPI::BlueprintImport.convert version: branch, single_file: false - File.open(Rails.application.root.join("docs/api/apiv3-oas-#{branch}.yml"), "w") do |f| + File.open(Rails.application.root.join("docs/api/apiv3/openapi-spec.yml"), "w") do |f| + f.write spec.to_yaml + end + end + + desc 'Saves the API spec (OAS3.0) to ./docs/api/openproject-apiv3-single.yml' + task :assemble_spec, [:branch] => [:environment] do |task, args| + branch = (args[:branch] || "stable").to_sym + spec = API::OpenAPI.spec + + File.open(Rails.application.root.join("docs/api/apiv3/openapi-spec-single.yml"), "w") do |f| f.write spec.to_yaml end end