/api/docs finished

- made into separate, lazy loaded js/css bundle in angular
- added setting allowing to disable API docs
- added spec lint workflow
- fixed styling (line-height)
- renamed/restructured
This commit is contained in:
Markus Kahl
2021-06-29 09:42:03 +01:00
parent 968ffc2ca6
commit 1a078ea667
19 changed files with 627 additions and 611 deletions
+10
View File
@@ -84,3 +84,13 @@ jobs:
run: |
docker-compose -f docker-compose.ci.yml down --remove-orphans -t 10
sudo chown -R $(whoami):$(id -ng) $CI_CACHE_PATH
api-spec:
name: APIv3 specification (OpenAPI 3.0)
if: github.repository == 'opf/openproject'
runs-on: [self-hosted-autoscale]
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '14'
- run: npx @redocly/openapi-cli lint docs/api/apiv3/openapi-spec.yml
+470
View File
@@ -0,0 +1,470 @@
# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API.
# See https://redoc.ly/docs/cli/ for more information.
docs/api/apiv3/openapi-spec.yml:
no-empty-servers:
- '#/servers'
info-license:
- '#/info'
no-ambiguous-paths:
- '#/paths/~1api~1v3~1queries~1{id}~1star'
- '#/paths/~1api~1v3~1queries~1{id}~1unstar'
- '#/paths/~1api~1v3~1relations~1{id}~1form'
- '#/paths/~1api~1v3~1work_packages~1{id}~1activities'
- '#/paths/~1api~1v3~1work_packages~1{id}~1attachments'
- '#/paths/~1api~1v3~1work_packages~1{id}~1available_projects'
- '#/paths/~1api~1v3~1work_packages~1{id}~1available_relation_candidates'
- '#/paths/~1api~1v3~1work_packages~1{id}~1available_watchers'
- '#/paths/~1api~1v3~1work_packages~1{id}~1form'
- '#/paths/~1api~1v3~1work_packages~1{id}~1revisions'
- '#/paths/~1api~1v3~1work_packages~1{work_package_id}~1relations'
- '#/paths/~1api~1v3~1work_packages~1{work_package_id}~1watchers'
docs/api/apiv3/paths/root.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
docs/api/apiv3/components/schemas/activity_model.yml:
spec:
- '#/properties/details/type'
docs/api/apiv3/paths/activity.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/components/schemas/attachment_model.yml:
spec:
- '#/properties/digest/type'
docs/api/apiv3/components/schemas/link.yml:
spec:
- '#/examples'
docs/api/apiv3/paths/attachments.yml:
no-invalid-media-type-examples:
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/filesize
- '#/post/responses/200/content/application~1hal+json/examples/response/value'
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/staticDownloadLocation
docs/api/apiv3/paths/attachment.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/filesize
- '#/get/responses/200/content/application~1hal+json/examples/response/value'
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/staticDownloadLocation
docs/api/apiv3/paths/budget.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/id
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/subject
docs/api/apiv3/paths/category.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/components/schemas/configuration_model.yml:
spec:
- '#/properties/perPageOptions/type'
docs/api/apiv3/paths/configuration.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/paths/custom_object.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/paths/document.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_embedded
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/description
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/addAttachment
docs/api/apiv3/components/schemas/grid_model.yml:
spec:
- '#/properties/widgets/type'
docs/api/apiv3/paths/grids.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/scope
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
docs/api/apiv3/paths/grid.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/scope
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
docs/api/apiv3/paths/news_item.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_embedded
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/description
docs/api/apiv3/paths/post.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_embedded
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/attachments
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/project
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/self
docs/api/apiv3/paths/priority.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/paths/project_copy.yml:
operation-2xx-response:
- '#/post/responses'
docs/api/apiv3/paths/project_work_packages.yml:
no-invalid-media-type-examples:
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/customField1
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/customField2
- '#/post/responses/200/content/application~1hal+json/examples/response/value'
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/startDate
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/dueDate
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/derivedStartDate
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/derivedDueDate
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/changeParent
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/customField3
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/delete
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/logTime
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/move
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/addWatcher/payload
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/customActions
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/watch/payload
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/ancestors
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/children
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/timeEntries/type
docs/api/apiv3/components/schemas/query_model.yml:
spec:
- '#/properties/filters/type'
- '#/properties/timelineLabels/type'
docs/api/apiv3/paths/queries.yml:
no-invalid-media-type-examples:
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_embedded
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_type
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_links/columns
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_links/groupBy
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_links/highlightedAttributes
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_links/project
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_links/results
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_links/self
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_links/sortBy
- >-
#/post/responses/201/content/application~1hal+json/examples/response/value/_links/user
docs/api/apiv3/paths/queries_column.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/components/schemas/query_filter_instance_schema_model.yml:
spec:
- '#/properties/name/type'
- '#/properties/filter/type'
docs/api/apiv3/paths/queries_filter.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/paths/queries_operator.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/paths/queries_sort_by.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/paths/query.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_embedded
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/columns
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/groupBy
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/highlightedAttributes
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/project
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/results
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/self
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/sortBy
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/user
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_embedded
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/columns
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/groupBy
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/highlightedAttributes
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/project
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/results
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/self
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/sortBy
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/user
docs/api/apiv3/paths/relation.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/delay
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/delay
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links
docs/api/apiv3/paths/revision.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/paths/role.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/paths/status.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/components/schemas/type_model.yml:
spec:
- '#/properties/color/type'
docs/api/apiv3/paths/type.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
docs/api/apiv3/paths/version.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/customField14
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/endDate
- '#/get/responses/200/content/application~1hal+json/examples/response/value'
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/customField4
docs/api/apiv3/paths/wiki_page.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_embedded
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/attachments
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/project
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/self
docs/api/apiv3/paths/work_packages.yml:
no-invalid-media-type-examples:
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/customField1
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/customField2
- '#/post/responses/200/content/application~1hal+json/examples/response/value'
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/startDate
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/dueDate
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/derivedStartDate
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/derivedDueDate
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/changeParent
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/customField3
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/delete
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/logTime
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/move
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/addWatcher/payload
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/customActions
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/watch/payload
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/ancestors
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/children
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/timeEntries/type
docs/api/apiv3/paths/work_packages_schemas.yml:
no-path-trailing-slash:
- '#/'
docs/api/apiv3/paths/work_package.yml:
no-invalid-media-type-examples:
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/customField1
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/customField2
- '#/get/responses/200/content/application~1hal+json/examples/response/value'
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/startDate
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/dueDate
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/derivedStartDate
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/derivedDueDate
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/changeParent
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/customField3
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/delete
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/logTime
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/move
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/addWatcher/payload
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/customActions
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/watch/payload
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/ancestors
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/children
- >-
#/get/responses/200/content/application~1hal+json/examples/response/value/_links/timeEntries/type
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/customField1
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/customField2
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/startDate
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/dueDate
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/derivedStartDate
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/derivedDueDate
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/changeParent
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/customField3
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/delete
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/logTime
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/move
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/addWatcher/payload
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/customActions
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/watch/payload
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/ancestors
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/children
- >-
#/patch/responses/200/content/application~1hal+json/examples/response/value/_links/timeEntries/type
docs/api/apiv3/paths/work_package_attachments.yml:
no-invalid-media-type-examples:
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_type
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/filesize
- '#/post/responses/200/content/application~1hal+json/examples/response/value'
- >-
#/post/responses/200/content/application~1hal+json/examples/response/value/_links/staticDownloadLocation
docs/api/apiv3/paths/work_package_relations.yml:
operation-2xx-response:
- '#/get/responses'
docs/api/apiv3/components/schemas/schema_model.yml:
spec:
- '#/properties/_dependencies/type'
docs/api/apiv3/components/schemas/time_entry_model.yml:
spec:
- '#/properties/hours/type'
@@ -28,9 +28,12 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class DocsController < ApplicationController
class APIDocsController < ApplicationController
before_action :require_login
layout 'angular'
def index
render_404 unless Setting.apiv3_docs_enabled?
end
end
@@ -29,6 +29,17 @@ See docs/COPYRIGHT.rdoc for more details.
<%= toolbar title: t(:label_api_access_key_type) %>
<%= styled_form_tag(admin_settings_update_api_path, method: :patch) do %>
<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t("setting_apiv3_docs") %></legend>
</fieldset>
<div class="form--field">
<%= setting_check_box :apiv3_docs_enabled %>
<div class="form--field-instructions">
<p><%= t(:setting_apiv3_docs_enabled_instructions_html, link: api_docs_url) %></p>
</div>
</div>
</section>
<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend">Cross-Origin Resource Sharing (CORS)</legend>
+1
View File
@@ -0,0 +1 @@
FOOBAR
-1
View File
@@ -1 +0,0 @@
<docs></docs>
+5
View File
@@ -2343,6 +2343,11 @@ en:
If CORS is enabled, these are the origins that are allowed to access OpenProject API.
<br/>
Please check the <a href="%{origin_link}" target="_blank">Documentation on the Origin header</a> on how to specify the expected values.
setting_apiv3_docs: "Documentation"
setting_apiv3_docs_enabled: "Enable docs page"
setting_apiv3_docs_enabled_instructions_html: >
If the docs page is enabled you can get an interactive view of the APIv3 documentation under
<a href="%{link}" target="_blank">%{link}</a>.
setting_email_delivery_method: "Email delivery method"
setting_sendmail_location: "Location of the sendmail executable"
setting_smtp_enable_starttls_auto: "Automatically use STARTTLS if available"
+1 -1
View File
@@ -38,7 +38,7 @@ OpenProject::Application.routes.draw do
get '/health_checks/all' => 'ok_computer/ok_computer#show', check: 'full'
mount OkComputer::Engine, at: "/health_checks"
get "/docs" => 'docs#index'
get "/api/docs" => 'api_docs#index'
# Redirect deprecated issue links to new work packages uris
get '/issues(/)' => redirect("#{rails_relative_url_root}/work_packages")
+3
View File
@@ -379,6 +379,9 @@ apiv3_cors_enabled:
apiv3_cors_origins:
serialized: true
default: []
apiv3_docs_enabled:
default: true
format: boolean
notification_retention_period_days:
default: 30
format: int
@@ -36,6 +36,10 @@ class Apiv3Paths {
this.apiV3Base = basePath + '/api/v3';
}
public get openApiSpecPath():string {
return this.apiV3Base + '/spec.json';
}
/**
* Preview markup path
*
@@ -57,6 +57,12 @@ export const OPENPROJECT_ROUTES:Ng2StateDeclaration[] = [
flash_message: { dynamic: true, value: null, inherit: false }
}
},
{
name: 'api-docs.**',
parent: 'root',
url: '/api/docs',
loadChildren: () => import('../../features/api-docs/openproject-api-docs.module').then(m => m.OpenprojectApiDocsModule)
},
{
name: 'boards.**',
parent: 'root',
@@ -135,7 +135,6 @@ 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,
@@ -222,7 +221,6 @@ export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: slideToggleSelector, cls: SlideToggleComponent },
{ selector: backupSelector, cls: BackupComponent },
{ selector: opInAppNotificationBellSelector, cls: InAppNotificationBellComponent },
{ selector: docsSelector, cls: DocsComponent }
];
@@ -26,23 +26,22 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import { AfterViewInit, Component, ViewEncapsulation } from '@angular/core';
import * as SwaggerUI from 'swagger-ui';
import { NgModule } from '@angular/core';
import { UIRouterModule } from "@uirouter/angular";
import { SwaggerUIComponent } from './swagger-ui/swagger-ui.component';
import { API_DOCS_ROUTES } from './openproject-api-docs.routes';
export const docsSelector = 'docs';
@Component({
selector: docsSelector,
styleUrls: ['./docs.component.sass'],
templateUrl: './docs.component.html',
encapsulation: ViewEncapsulation.None
@NgModule({
imports: [
// Routes for /backlogs
UIRouterModule.forChild({
states: API_DOCS_ROUTES
}),
],
declarations: [
SwaggerUIComponent
]
})
export class DocsComponent implements AfterViewInit {
ngAfterViewInit() {
SwaggerUI({
dom_id: '#swagger',
url: document.location.href.replace("docs", "api/v3/spec.json"),
filter: true
});
}
export class OpenprojectApiDocsModule {
}
@@ -0,0 +1,39 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import { Ng2StateDeclaration, UIRouter } from "@uirouter/angular";
import { SwaggerUIComponent } from "./swagger-ui/swagger-ui.component";
export const API_DOCS_ROUTES:Ng2StateDeclaration[] = [
{
name: 'api-docs',
parent: 'root',
url: '/api/docs',
component: SwaggerUIComponent
}
];
@@ -0,0 +1,57 @@
//-- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2021 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See docs/COPYRIGHT.rdoc for more details.
//++
import { AfterViewInit, Component, ViewEncapsulation } from '@angular/core';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import * as SwaggerUI from 'swagger-ui';
@Component({
selector: 'op-api-docs',
styleUrls: ['./swagger-ui.component.sass'],
templateUrl: './swagger-ui.component.html',
encapsulation: ViewEncapsulation.None
})
export class SwaggerUIComponent implements AfterViewInit {
constructor(private pathHelperService: PathHelperService) {
}
ngAfterViewInit() {
SwaggerUI({
dom_id: '#swagger',
url: this.pathHelperService.api.v3.openApiSpecPath,
filter: true,
requestInterceptor: (req) => {
if (!req.loadSpec) {
// required to make session-based authentication work for POST requests with APIv3
req.headers["X-Requested-With"] = "XMLHttpRequest";
}
return req;
}
});
}
}
@@ -204,7 +204,6 @@ $acronym-underline: 1px dotted #ddd !default;
small {
font-size: $small-font-size;
color: $small-font-color;
line-height: 0;
}
}
-588
View File
@@ -1,588 +0,0 @@
module API
module OpenAPI
module BlueprintImport
extend self
def assemble_file(input_path:, output_path:)
File.open(output_path, "w") do |f|
f.write read_file(input_path).gsub(/\t/, ' ')
end
end
def read_file(path)
bp = File.read path
bp.gsub(include_directive_regex).each do |_match|
read_file Pathname(path).parent.join($1).to_s
end
end
def include_directive_regex
@include_directive_regex ||= /\<\!\-\-\s*include\((.*)\)\s*\-\-\>/
end
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
spec = YAML.load %x`api-spec-converter -f api_blueprint -t openapi_3 --syntax=yaml #{md_file}`
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"] = {
"BasicAuth" => {
"type" => "http",
"scheme" => "basic"
}
}
spec["security"] = [
{ "BasicAuth" => [] }
]
end
def amend_schemas!(spec, apibp:)
schemas = schema_names spec
spec["tags"].each do |tag|
schema = schema_from_tag tag, schema_names: schemas
if schema
key = schema.keys.first.underscore.split("_").map(&:capitalize).join("_") + "Model"
spec["components"]["schemas"][key] = schema.values.first
end
end
add_formattable_schema! spec
add_link_schema! spec
add_missing_models! spec, apibp: apibp
spec["components"]["schemas"] = spec["components"]["schemas"].sort.to_h
end
def add_formattable_schema!(spec)
spec["components"]["schemas"]["Formattable"] = {
"type" => "object",
"required" => ["format"],
"properties" => {
"format" => {
"type" => "string",
"enum" => ["plain", "markdown", "custom"],
"readOnly" => true,
"description" => "Indicates the formatting language of the raw text",
"example" => "markdown"
},
"raw" => {
"type" => "string",
"description" => "The raw text, as entered by the user",
"example" => "I **am** formatted!"
},
"html" => {
"type" => "string",
"readOnly" => true,
"description" => "The text converted to HTML according to the format",
"example" => "I <strong>am</strong> formatted!"
}
},
"example" => { "format" => "markdown", "raw" => "I am formatted!", "html" => "I am formatted!" }
}
end
def add_link_schema!(spec)
spec["components"]["schemas"]["Link"] = {
"type" => "object",
"required" => ["href"],
"properties" => {
"href" => {
"type" => "string",
"nullable" => true,
"description" => "URL to the referenced resource (might be relative)"
},
"title" => {
"type" => "string",
"description" => " Representative label for the resource"
},
"templated" => {
"type" => "boolean",
"default" => false,
"description" => "If true the href contains parts that need to be replaced by the client"
},
"method" => {
"type" => "string",
"default" => "GET",
"description" => "The HTTP verb to use when requesting the resource",
},
"payload" => {
"type" => "string",
"description" => "The payload to send in the request to achieve the desired result"
},
"identifier" => {
"type" => "string",
"description" => " An optional unique identifier to the link object"
}
},
"examples" => [
{ "href" => nil },
{ "href" => "/api/v3/work_packages", "method" => "POST" },
{ "href" => "/api/v3/examples/{example_id}", "templated" => true },
{ "href" => "urn:openproject-org:api:v3:undisclosed" }
]
}
end
def add_missing_models!(spec, apibp:)
lines = apibp.lines.to_a
model_candidates = lines.select { |l| l.strip.start_with?("## ") && l.strip.end_with?("]") && l.include?("[/") }
model_candidates.each do |model|
extract_model_example! spec, model, lines
end
end
def extract_model_example!(spec, heading, lines)
model_lines = lines
.drop(lines.index(heading))
.drop(1)
.take_while { |l| not l.strip.start_with?("#") }
return unless model_lines.include? "+ Model\n"
model_name = heading[(heading.index(" "))..(heading.index("[") - 1)].strip
json = model_lines
.drop_while { |l| not l.start_with?(" " * 8) }
.take_while { |l| l.start_with?(" " * 8) || l.strip.blank? }
.join
begin
key = model_name.gsub(" ", "_") + "Model"
example = JSON.parse json
spec["components"]["schemas"][key] = Hash(spec["components"]["schemas"][key]).deep_merge({
"type" => "object",
"example" => example
})
unused_key = key.sub(/Model\Z/, "")
spec["components"]["schemas"].delete unused_key if spec["components"]["schemas"][unused_key].blank?
rescue => e
case model_name
when 'Markdown', 'Plain Text'
spec["components"]["schemas"][key] = Hash(spec["components"]["schemas"][key]).deep_merge({
"type" => "string",
"format" => "html",
"example" => json.strip
})
else
STDERR.puts "Failed to parse model example for #{model_name}: #{e.message}"
end
end
end
def schema_names(spec)
names = spec["paths"]
.values
.flat_map { |p|
p.values.flat_map { |v| v["tags"] }
}
.uniq
.map(&:singularize)
.map { |n| n.gsub(" ", "") }
.reject { |n| n == 'Actions&Capability' }
names << 'ActionsAndCapabilities'
names
end
def schema_from_tag(tag, schema_names:)
name = tag["name"].singularize.gsub(" ", "")
return nil unless schema_names.include? name
{
name => schema_object(name, tag["description"], schema_names: schema_names)
}
end
def schema_object(name, description, schema_names:)
properties, required_properties = local_properties description: description, schema_names: schema_names
actions, _ = link_properties description, heading: "Actions", read_only: true
links, required_links = link_properties description, heading: "Linked Properties"
links = Hash(actions).merge Hash(links)
if links.present?
properties ||= {}
properties["_links"] = {
"type" => "object",
"required" => required_links,
"properties" => links
}
.reject { |k, v| v.nil? }
end
{
"type" => "object",
"required" => required_properties,
"properties" => properties
}
.reject { |k, v| v.nil? }
end
def link_properties(description, heading:, read_only: nil)
lines = description
.lines
.drop_while { |l| not l =~ /## #{heading}/i }
.drop_while { |l| not l =~ /\A\|\s*Link\s*\|/ }
.take_while { |l| l =~ /\A\|/ }
lines.delete_at 1 # delete header line
data = lines.map { |l| l.split("|")[1..-2].map(&:strip) }
return nil if data.empty?
header = data.first
name_index = header.index "Link"
desc_index = header.index "Description"
type_index = header.index "Type"
cons_index = header.index "Constraints"
sops_index = header.index "Supported operations"
cond_index = header.index "Condition"
required = []
properties = data[1..-1].map do |row|
name = row[name_index]
type = (type_index && String(row[type_index].presence)) || 'object'
link = {}
value = {
"allOf" => [{ "$ref" => "./link.yml" }, link]
}
set_description! link, row, desc_index
set_read_write! link, row, sops_index
set_constraints! link, row, cons_index
if !read_only.nil?
link["readOnly"] = true
end
if type_index
if link["description"].present?
link["description"] = "#{link['description']}\n\n**Resource**: #{row[type_index]}"
else
link["description"] = "**Resource**: #{row[type_index]}"
end
end
required << name if property_required?(row, cons_index)
add_conditions! link, row, cond_index
[name, value]
end
[properties.to_h, required.presence]
end
def local_properties(description:, schema_names:)
lines = description
.lines
.drop_while { |l| not l =~ /## Local Properties/i }
.drop_while { |l| not l =~ /\A\|\s*Property\s*\|/ }
.take_while { |l| l =~ /\A\|/ }
lines.delete_at 1 # delete header line
data = lines.map { |l| l.split("|")[1..-2].map(&:strip) }
return nil if data.empty?
header = data.first
name_index = header.index "Property"
desc_index = header.index "Description"
type_index = header.index "Type"
cons_index = header.index "Constraints"
sops_index = header.index "Supported operations"
cond_index = header.index "Condition"
required = []
properties = data[1..-1].map do |row|
name = row[name_index]
type = (type_index && String(row[type_index].presence)) || 'object'
if schema_names.include? type
next [name, { '$ref' => '#/components/schemas/#{type}' }]
end
value = map_type type
set_description! value, row, desc_index
set_read_write! value, row, sops_index
set_constraints! value, row, cons_index
required << name if property_required?(row, cons_index)
if name == "language"
if value.include? "description"
value["description"] = "#{value['description']} | ISO 639-1 format"
else
value["description"] = "ISO 639-1 format"
end
end
add_conditions! value, row, cond_index
if type == "Formattable"
value.delete "type"
value = {
"allOf" => [
{ "$ref" => "./formattable.yml" },
value
]
}
end
[name, value]
end
[properties.to_h, required.presence]
end
def type_in_schemas?(type)
["formattable"].include? type
end
def add_conditions!(data, row, index)
value = index && String(row[index]).presence
return unless value
if data.include? "description"
data["description"] = "#{data['description']}\n\n# Conditions\n\n#{value}"
else
data["description"] = "# Conditions\n\n#{value}"
end
end
def set_constraints!(data, row, index)
return if index.nil?
value = String(row[index])
set_minimum! data, value
set_maximum! data, value
set_min_max_length! data, value
end
def set_enum!(data, value)
return unless value.downcase.strip.starts_with? "in: "
values = value.split(":").last.strip
values = "[#{values}]" unless values.starts_with? "["
data["enum"] = YAML.load values
end
def set_min_max_length!(data, value)
return unless data["type"] == "string"
if value.downcase.include?('not empty')
data["minLength"] = 1
elsif value =~ /(\d+)\s+min\s+length/i
data["minLength"] = $1.to_i
elsif value =~ /(\d+)\s+max\s+length/i
data["maxLength"] = $1.to_i
end
end
def set_minimum!(data, value)
return unless value =~ /x\s+>(=)?\s+(\d+)/
data["minimum"] = $2.to_i
data["exclusiveMinimum"] = true unless $1
end
def set_maximum!(data, value)
return unless value =~ /x\s+<(=)?\s+(\d+)/
data["maximum"] = $2.to_i
data["exclusiveMaximum"] = true unless $1
end
def property_required?(row, index)
return false if index.nil?
String(row[index]).downcase.include? 'not null'
end
def set_read_write!(data, row, sops_index)
return if sops_index.nil?
value = String(row[sops_index]).downcase
read = value.include? "read"
write = value.include? "write"
if read and not write
data["readOnly"] = true
elsif write and not read
data["writeOnly"] = true
end
end
def set_description!(data, row, index)
return nil unless index
value = String(row[index])
data["description"] = value if value.present?
end
def map_type(type)
value = type.downcase
case value
when 'date'
{ 'type' => 'string', 'format' => 'date' }
when 'datetime'
{ 'type' => 'string', 'format' => 'date-time' }
when 'url'
{ 'type' => 'string', 'format' => 'uri' }
when 'duration'
{ 'type' => 'string', 'format' => 'duration' }
else
{ 'type' => value }
end
end
end
end
end