Skip to content

Support rendering openapi and swagger documents #26802

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
_ "code.gitea.io/gitea/modules/markup/console"
_ "code.gitea.io/gitea/modules/markup/csv"
_ "code.gitea.io/gitea/modules/markup/markdown"
_ "code.gitea.io/gitea/modules/markup/openapi"
_ "code.gitea.io/gitea/modules/markup/orgmode"

"github.com/urfave/cli/v2"
Expand Down
90 changes: 90 additions & 0 deletions modules/markup/openapi/openapi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Copyright 2023 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package openapi

import (
"fmt"
"io"
"net/url"

"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting"

"github.com/gobwas/glob"
)

func init() {
markup.RegisterRenderer(Renderer{})
}

// Renderer implements markup.Renderer for openapi files.
type Renderer struct{}

var (
_ markup.RendererRelativePathDetector = (*Renderer)(nil)
g = glob.MustCompile("**{openapi,OpenAPI,swagger}.{yml,yaml,json,JSON,Yaml,YML}", '/')
)

// Name implements markup.Renderer
func (Renderer) Name() string {
return "openapi"
}

// SanitizerDisabled disabled sanitize if return true
func (Renderer) SanitizerDisabled() bool {
return true
}

func (Renderer) DisplayInNewPage() bool {
return true
}

func (Renderer) CanRenderRelativePath(relativePath string) bool {
return g.Match(relativePath)
}

// Extensions implements markup.Renderer
func (Renderer) Extensions() []string {
return nil
}

// SanitizerRules implements markup.Renderer
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return []setting.MarkupSanitizerRule{
{Element: "script", AllowAttr: "src"},
}
}

// Render implements markup.Renderer
func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error {
rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s",
setting.AppSubURL,
url.PathEscape(ctx.Metas["user"]),
url.PathEscape(ctx.Metas["repo"]),
ctx.Metas["BranchNameSubURL"],
ctx.RelativePath,
)

if _, err := io.WriteString(output, fmt.Sprintf(
`<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="%s/assets/css/swagger.css?v=%s">
</head>
<body>
<div id="swagger-ui" data-source="%s"></div>
<script src="%s/assets/js/swagger.js?v=%s"></script>
</body>
</html>`,
setting.StaticURLPrefix,
setting.AssetVersion,
rawURL,
setting.StaticURLPrefix,
setting.AssetVersion,
)); err != nil {
return err
}
return nil
}
87 changes: 66 additions & 21 deletions modules/markup/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,21 +172,34 @@ type PostProcessRenderer interface {
NeedPostProcess() bool
}

// PostProcessRenderer defines an interface for external renderers
type ExternalRenderer interface {
type SanitizerDisabledRenderer interface {
// SanitizerDisabled disabled sanitize if return true
SanitizerDisabled() bool
}

// PostProcessRenderer defines an interface for external renderers
type ExternalRenderer interface {
SanitizerDisabledRenderer

// DisplayInIFrame represents whether render the content with an iframe
DisplayInIFrame() bool
}

type NewPageRenderer interface {
DisplayInNewPage() bool
}

// RendererContentDetector detects if the content can be rendered
// by specified renderer
type RendererContentDetector interface {
CanRender(filename string, input io.Reader) bool
}

// RendererRelativePathDetector detects if the content can be rendered according relative file path
type RendererRelativePathDetector interface {
CanRenderRelativePath(relativePath string) bool
}

var (
extRenderers = make(map[string]Renderer)
renderers = make(map[string]Renderer)
Expand All @@ -203,7 +216,21 @@ func RegisterRenderer(renderer Renderer) {
// GetRendererByFileName get renderer by filename
func GetRendererByFileName(filename string) Renderer {
extension := strings.ToLower(filepath.Ext(filename))
return extRenderers[extension]
renderer := extRenderers[extension]
if renderer != nil {
return renderer
}
return GetRendererByRelativePathInterface(filename)
}

// GetRendererByRelativePathInterface returns a renderer according relative file path
func GetRendererByRelativePathInterface(relativePath string) Renderer {
for _, renderer := range renderers {
if detector, ok := renderer.(RendererRelativePathDetector); ok && detector.CanRenderRelativePath(relativePath) {
return renderer
}
}
return nil
}

// GetRendererByType returns a renderer according type
Expand Down Expand Up @@ -284,7 +311,7 @@ func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Wr
var pw2 io.WriteCloser

var sanitizerDisabled bool
if r, ok := renderer.(ExternalRenderer); ok {
if r, ok := renderer.(SanitizerDisabledRenderer); ok {
sanitizerDisabled = r.SanitizerDisabled()
}

Expand Down Expand Up @@ -342,33 +369,51 @@ func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error {
return ErrUnsupportedRenderType{ctx.Type}
}

// ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render
type ErrUnsupportedRenderExtension struct {
Extension string
// ErrUnsupportedRenderFile represents the error when extension or filename doesn't supported to render
type ErrUnsupportedRenderFile struct {
RelativePath string
}

func IsErrUnsupportedRenderExtension(err error) bool {
_, ok := err.(ErrUnsupportedRenderExtension)
func IsErrUnsupportedRenderFile(err error) bool {
_, ok := err.(ErrUnsupportedRenderFile)
return ok
}

func (err ErrUnsupportedRenderExtension) Error() string {
return fmt.Sprintf("Unsupported render extension: %s", err.Extension)
func (err ErrUnsupportedRenderFile) Error() string {
return fmt.Sprintf("Unsupported render file: %s", err.RelativePath)
}

func renderButton(ctx *RenderContext, output io.Writer) error {
_, err := io.WriteString(output, fmt.Sprintf(`<iframe src="%s/%s/%s/render/%s/%s" sandbox="allow-same-origin allow-scripts"></iframe>`,
setting.AppSubURL,
url.PathEscape(ctx.Metas["user"]),
url.PathEscape(ctx.Metas["repo"]),
ctx.Metas["BranchNameSubURL"],
url.PathEscape(ctx.RelativePath),
))
return err
}

func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error {
extension := strings.ToLower(filepath.Ext(ctx.RelativePath))
if renderer, ok := extRenderers[extension]; ok {
if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() {
if !ctx.InStandalonePage {
// for an external render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, output)
}
renderer := GetRendererByFileName(ctx.RelativePath)
if renderer == nil {
return ErrUnsupportedRenderFile{ctx.RelativePath}
}

if r, ok := renderer.(NewPageRenderer); ok && r.DisplayInNewPage() {
if !ctx.InStandalonePage {
return renderButton(ctx, output)
}
}

if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() {
if !ctx.InStandalonePage {
// for an external render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, output)
}
return render(ctx, renderer, input, output)
}
return ErrUnsupportedRenderExtension{extension}
return render(ctx, renderer, input, output)
}

// Type returns if markup format via the filename
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"htmx.org": "1.9.11",
"idiomorph": "0.3.0",
"jquery": "3.7.1",
"js-yaml": "4.1.0",
"katex": "0.16.10",
"license-checker-webpack-plugin": "0.2.1",
"mermaid": "10.9.0",
Expand Down
2 changes: 1 addition & 1 deletion routers/api/v1/misc/markup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Here are some links to the most important topics. You can find the full list of
testRenderMarkup(t, "file", "path/test.md", text, response, http.StatusOK)
}

testRenderMarkup(t, "file", "path/test.unknown", "## Test", "Unsupported render extension: .unknown\n", http.StatusUnprocessableEntity)
testRenderMarkup(t, "file", "path/test.unknown", "## Test", "Unsupported render file: path/test.unknown\n", http.StatusUnprocessableEntity)
testRenderMarkup(t, "unknown", "", "## Test", "Unknown mode: unknown\n", http.StatusUnprocessableEntity)
}

Expand Down
3 changes: 1 addition & 2 deletions routers/common/markup.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,10 @@ func RenderMarkup(ctx *context.Base, repo *context.Repository, mode, text, urlPr
Type: markupType,
RelativePath: relativePath,
}, strings.NewReader(text), ctx.Resp); err != nil {
if markup.IsErrUnsupportedRenderExtension(err) {
if markup.IsErrUnsupportedRenderFile(err) {
ctx.Error(http.StatusUnprocessableEntity, err.Error())
} else {
ctx.Error(http.StatusInternalServerError, err.Error())
}
return
}
}
7 changes: 5 additions & 2 deletions routers/web/repo/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func RenderFile(ctx *context.Context) {
isTextFile := st.IsText()

rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{})
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts")
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts allow-same-origin")

if markupType := markup.Type(blob.Name()); markupType == "" {
if isTextFile {
Expand All @@ -56,6 +56,9 @@ func RenderFile(ctx *context.Context) {
return
}

metaData := ctx.Repo.Repository.ComposeDocumentMetas(ctx)
metaData["BranchNameSubURL"] = ctx.Repo.BranchNameSubURL()

err = markup.Render(&markup.RenderContext{
Ctx: ctx,
RelativePath: ctx.Repo.TreePath,
Expand All @@ -64,7 +67,7 @@ func RenderFile(ctx *context.Context) {
BranchPath: ctx.Repo.BranchNameSubURL(),
TreePath: path.Dir(ctx.Repo.TreePath),
},
Metas: ctx.Repo.Repository.ComposeDocumentMetas(ctx),
Metas: metaData,
GitRepo: ctx.Repo.GitRepo,
InStandalonePage: true,
}, rd, ctx.Resp)
Expand Down
1 change: 1 addition & 0 deletions web_src/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
--height-loading: 16rem;
--min-height-textarea: 132px; /* padding + 6 lines + border = calc(1.57142em + 6lh + 2px), but lh is not fully supported */
--tab-size: 4;
--render-height: 600px;
--checkbox-size: 15px; /* height and width of checkbox and radio inputs */
--page-spacing: 16px; /* space between page elements */
--page-margin-x: 32px; /* minimum space on left and right side of page */
Expand Down
13 changes: 12 additions & 1 deletion web_src/css/repo.css
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ td .commit-summary {

.pdf-content {
width: 100%;
height: 600px;
height: var(--render-height);
border: none !important;
display: flex;
align-items: center;
Expand Down Expand Up @@ -1788,6 +1788,17 @@ td .commit-summary {
.file-view.markup {
padding: 1em 2em;
}

.file-view.openapi {
padding: 0;
}

.file-view.openapi iframe {
width: 100%;
height: var(--render-height);
border: none;
}

.repository .activity-header {
display: flex;
justify-content: space-between;
Expand Down
26 changes: 16 additions & 10 deletions web_src/js/standalone/swagger.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js';
import 'swagger-ui-dist/swagger-ui.css';
import {parseUrl} from '../utils.js';
import {load} from 'js-yaml';

// This code is shared for our own spec as well as user-defined specs via files in repo
window.addEventListener('load', async () => {
const url = document.getElementById('swagger-ui').getAttribute('data-source');
const url = parseUrl(document.getElementById('swagger-ui').getAttribute('data-source'));
const res = await fetch(url);
const spec = await res.json();
const text = await res.text();
const spec = /\.ya?ml$/i.test(url.pathname) ? load(text) : JSON.parse(text);
const isOwnSpec = url.pathname.endsWith('/swagger.v1.json');

// Make the page's protocol be at the top of the schemes list
const proto = window.location.protocol.slice(0, -1);
spec.schemes.sort((a, b) => {
if (a === proto) return -1;
if (b === proto) return 1;
return 0;
});
if (isOwnSpec) {
// Make the page's protocol be at the top of the schemes list
const proto = window.location.protocol.slice(0, -1);
spec.schemes.sort((a, b) => {
if (a === proto) return -1;
if (b === proto) return 1;
return 0;
});
}

const ui = SwaggerUI({
spec,
dom_id: '#swagger-ui',
deepLinking: true,
docExpansion: 'none',
defaultModelRendering: 'model', // don't show examples by default, because they may be incomplete
presets: [
SwaggerUI.presets.apis,
],
Expand Down