From 2b00d12bb61718d7b7577c58f123fa966c523894 Mon Sep 17 00:00:00 2001 From: Brad Rydzewski Date: Tue, 6 Oct 2015 10:19:43 -0700 Subject: [PATCH] code to generate API documentation --- Makefile | 14 +- contrib/generate-api-docs.go | 219 +++++++++++++++ docs/api.raml | 499 ---------------------------------- static/styles/pages/docs.sass | 202 ++++++++++++++ static/styles/style.sass | 1 + static/styles_gen/style.css | 80 ++++++ template/amber/swagger.amber | 65 +++++ 7 files changed, 579 insertions(+), 501 deletions(-) create mode 100644 contrib/generate-api-docs.go delete mode 100644 docs/api.raml create mode 100644 static/styles/pages/docs.sass create mode 100644 template/amber/swagger.amber diff --git a/Makefile b/Makefile index e5e58046b..6a28cd38f 100644 --- a/Makefile +++ b/Makefile @@ -13,9 +13,19 @@ deps: go get -u github.com/elazarl/go-bindata-assetfs/... go get -u github.com/dchest/jsmin go get -u github.com/franela/goblin + go get -u github.com/go-swagger/go-swagger -gen: - go generate $(PACKAGES) +gen: gen_static gen_template gen_migrations + +gen_static: + mkdir -p static/docs_gen/api + go generate github.com/drone/drone/static + +gen_template: + go generate github.com/drone/drone/template + +gen_migrations: + go generate github.com/drone/drone/shared/database build: go build diff --git a/contrib/generate-api-docs.go b/contrib/generate-api-docs.go new file mode 100644 index 000000000..c0f16c071 --- /dev/null +++ b/contrib/generate-api-docs.go @@ -0,0 +1,219 @@ +// +build ignore + +// This program generates api documentation from a +// swaggerfile using an amber template. + +package main + +import ( + "crypto/md5" + "flag" + "fmt" + "io" + "log" + "os" + + "github.com/eknkc/amber" + "github.com/go-swagger/go-swagger/spec" +) + +var ( + templ = flag.String("template", "index.amber", "") + input = flag.String("input", "swagger.json", "") + output = flag.String("output", "", "") +) + +func main() { + flag.Parse() + + // parses the swagger spec file + spec, err := spec.YAMLSpec(*input) + if err != nil { + log.Fatal(err) + } + swag := spec.Spec() + + // create output source for file. defaults to + // stdout but may be file. + var w io.WriteCloser = os.Stdout + if *output != "" { + w, err = os.Create(*output) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + return + } + defer w.Close() + } + + // we wrap the swagger file in a map, otherwise it + // won't work with our existing templates, which expect + // a map as the root parameter. + var data = map[string]interface{}{ + "Swagger": normalize(swag), + } + + t := amber.MustCompileFile(*templ, amber.DefaultOptions) + err = t.Execute(w, data) + if err != nil { + log.Fatal(err) + } +} + +// Swagger is a simplified representation of the swagger +// document with a subset of the fields used to generate +// our API documentation. +type Swagger struct { + Tags []Tag +} + +type Tag struct { + Name string + Ops []Operation +} + +type Operation struct { + ID string + Method string + Path string + Desc string + Summary string + + Params []Param + Results []Result +} + +type Param struct { + Name string + Desc string + Type string + Example interface{} + InputTo string + IsObject bool +} + +type Result struct { + Status int + Desc string + Example interface{} + IsObject bool + IsArray bool +} + +// normalize is a helper function that normalizes the swagger +// file to a simpler format that makes it easier to work with +// inside the template. +func normalize(swag *spec.Swagger) Swagger { + swag_ := Swagger{} + + for _, tag := range swag.Tags { + tag_ := Tag{Name: tag.Name} + + // group the paths based on their tag value. + for route, path := range swag.Paths.Paths { + + var ops = []*spec.Operation{ + path.Get, + path.Put, + path.Post, + path.Patch, + path.Delete, + } + + // flatten the operations into an array and convert + // the underlying data so that it is a bit easier to + // work with. + for _, op := range ops { + + // the operation must have a tag to + // be rendered in our custom template. + if op == nil || !hasTag(tag.Name, op.Tags) { + continue + } + + item := Operation{} + item.Path = route + item.Method = getMethod(op, path) + item.Desc = op.Description + item.Summary = op.Summary + item.ID = fmt.Sprintf("%x", md5.Sum([]byte(item.Path+item.Method))) + + // convert the operation input parameters into + // our internal format so that it is easier to + // work with in the template. + for _, param := range op.Parameters { + param_ := Param{} + param_.Name = param.Name + param_.Desc = param.Description + param_.Type = param.Type + param_.IsObject = param.Schema != nil + param_.InputTo = param.In + + if param_.IsObject { + param_.Type = param.Schema.Ref.GetPointer().String()[13:] + param_.Example = param.Schema.Example + } + item.Params = append(item.Params, param_) + } + + // convert the operation response types into + // our internal format so that it is easier to + // work with in the template. + for code, resp := range op.Responses.StatusCodeResponses { + result := Result{} + result.Desc = resp.Description + result.Status = code + result.IsObject = resp.Schema != nil + if result.IsObject { + result.IsArray = resp.Schema.Items != nil + + name := resp.Schema.Ref.GetPointer().String() + if len(name) != 0 { + def, _ := swag.Definitions[name[13:]] + result.Example = def.Example + } + } + if result.IsArray { + name := resp.Schema.Items.Schema.Ref.GetPointer().String() + def, _ := swag.Definitions[name[13:]] + result.Example = def.Example + } + item.Results = append(item.Results, result) + } + tag_.Ops = append(tag_.Ops, item) + } + } + + swag_.Tags = append(swag_.Tags, tag_) + } + + return swag_ +} + +// hasTag is a helper function that returns true if +// an operation has the specified tag label. +func hasTag(want string, in []string) bool { + for _, got := range in { + if got == want { + return true + } + } + return false +} + +// getMethod is a helper function that returns the http +// method for the specified operation in a path. +func getMethod(op *spec.Operation, path spec.PathItem) string { + switch { + case op == path.Get: + return "GET" + case op == path.Put: + return "PUT" + case op == path.Patch: + return "PATCH" + case op == path.Post: + return "POST" + case op == path.Delete: + return "DELETE" + } + return "" +} diff --git a/docs/api.raml b/docs/api.raml deleted file mode 100644 index 02d41f03b..000000000 --- a/docs/api.raml +++ /dev/null @@ -1,499 +0,0 @@ -#%RAML 0.8 - -title: Drone API -baseUri: http://localhost:8080/api -traits: - - authenticate: - description: Some requests require authentication. - queryParameters: - access_token: - description: Access Token - type: string - example: ACCESS_TOKEN - - authorize: - responses: - 401: - description: | - Access Denied. User must be authenticated. - 403: - description: | - Access Forbidden. User must be a system administrator. - -/repos/{owner}/{name}: - is: [ authorize, authenticate ] - uriParameters: - owner: - displayName: Owner - description: Owner of the repository - type: string - required: true - name: - displayName: Name - description: Name of the repository - type: string - required: true - - get: - displayName: Find Repository - description: | - Retrieve a Repository by Name - responses: - 200: - body: - application/json: - example: !include samples/repo.json - 404: - description: | - Unable to find the Repository in the database - - patch: - displayName: Update Repository - description: | - Update a Repository - responses: - 200: - body: - application/json: - example: !include samples/repo.json - 400: - description: | - Unable to update the Repository record in the database - 404: - description: | - Unable to find the Repository in the database - - post: - displayName: Activate Repository - description: | - Activate a Repository - responses: - 200: - body: - application/json: - example: !include samples/repo.json - 400: - description: | - Unable to update the Repository record in the database - 403: - description: | - Unable to activate the Repository due to insufficient privileges - 404: - description: | - Unable to retrieve the Repository from the remote system (ie GitHub) - 409: - description: | - Unable to activate the Repository because it is already activate - 500: - description: | - Unable to activate the Repository due to an internal server error. - This may indicate a problem adding hooks to the remote system (ie Github), - generating SSH deployment keys, or persisting to the database. - - delete: - displayName: Delete Repository - description: | - Deletes a Repository - responses: - 200: - description: | - Successfully deleted the Repository - 400: - description: | - Unable to remove post-commit hooks from the remote system (ie GitHub) - 404: - description: | - Unable to find the Repository in the database - 500: - description: | - Unable to update the Repository record in the database - - /watch: - is: [ authorize, authenticate ] - description: | - Watch the Repository - post: - responses: - 200: - description: | - Successfully Watching this Repository - 400: - description: | - Unable to insert the Starred record in the database - 404: - description: | - Unable to find the Repository in the database - - /unwatch: - is: [ authorize, authenticate ] - description: | - Unwatch the Repository - delete: - responses: - 200: - description: | - Successfully Unwatched this Repository - 400: - description: | - Unable to delete the Starred record in the database - 404: - description: | - Unable to find the Repository in the database - - -# -# Builds -# - -/repos/{owner}/{name}/builds: - displayName: Builds - is: [ authorize, authenticate ] - uriParameters: - owner: - displayName: Owner - description: Owner of the repository - type: string - required: true - name: - displayName: Name - description: Name of the repository - type: string - required: true - - get: - displayName: List Builds - description: | - Retrieve the list of recent Builds for the Repository - responses: - 200: - body: - application/json: - example: !include samples/builds.json - 404: - description: | - Unable to find the Repository in the database - - /{number}: - is: [ authorize, authenticate ] - uriParameters: - number: - displayName: Number - type: integer - - get: - displayName: Find Build - description: | - Retrieve a specific Build by Number - responses: - 200: - body: - application/json: - example: !include samples/build.json - 404: - description: | - Unable to find the Build in the database - - delete: - displayName: Cancel Build - description: | - Cancel an running Build by Number - responses: - 200: - description: | - Successfully cancelled the Build - 404: - description: | - Cannot find Build by Number - 409: - description: | - Cannot cancel a Build that is already stopped - - post: - displayName: Restart Build - description: | - Restart a completed Build - responses: - 202: - description: | - Successfully restarted the Build - 404: - description: | - Cannot find Build by Number - 409: - description: | - Cannot re-start a Build that is running - -# -# Build Logs -# - - -/repos/{owner}/{name}/logs/{number}/{job}: - displayName: Builds - is: [ authorize, authenticate ] - uriParameters: - owner: - displayName: Owner - description: Owner of the repository - type: string - required: true - name: - displayName: Name - description: Name of the repository - type: string - required: true - number: - displayName: Build Number - description: Incremental Build Number - type: integer - required: true - job: - displayName: Job Number - description: Sequential Job Number - type: integer - required: true - - get: - displayName: Find Build Logs - description: | - Retrieve the Logs for a specific Build Job - responses: - 200: - body: - text/plain: - 404: - description: | - Cannot find Build Logs - - -# -# User Endpoint -# - -/user: - is: [ authorize, authenticate ] - displayName: User - - get: - description: | - Retrieve the currently authenticated User - responses: - 200: - body: - application/json: - example: !include samples/user.json - - patch: - description: | - Update the currently authenticated User - responses: - 200: - description: | - Updated User - body: - application/json: - example: !include samples/user.json - 400: - description: | - Bad Request. Error updating User - - - -# -# User Repos -# - -/user/repos: - is: [ authorize, authenticate ] - displayName: User Repos - - get: - description: | - Retrieve the currently authenticated User's Repository list - responses: - 200: - body: - application/json: - example: !include samples/repos.json - 400: - description: | - Bad Request. Error retrieving Repository list - - -# -# User Tokens -# - -/user/tokens: - is: [ authorize, authenticate ] - displayName: User Tokens - - get: - description: | - Retrieve the currently authenticated User's active Token list - responses: - 200: - body: - application/json: - example: !include samples/tokens.json - 400: - description: | - Error retrieving the Token list - - post: - description: | - Generate a new User Token - responses: - 200: - body: - application/json: - example: !include samples/token.json - 400: - description: | - Error when attempting to generate the JWT token - 500: - description: | - Error when attempting to save the Token - - /{label}: - is: [ authorize, authenticate ] - delete: - description: | - Delete a specific User Token by Label - responses: - 200: - description: | - Successfully deleted the Token - 400: - description: | - Error attempting to delete Token from database - 404: - description: | - Unable to find Token in database - -# -# Users Endpoint -# - -/users: - is: [ authorize, authenticate ] - displayName: Users - - get: - description: Retrieve a list of all registered Users. - displayName: List Users - responses: - 200: - body: - application/json: - example: !include samples/users.json - - /{login}: - is: [ authorize, authenticate ] - uriParameters: - login: - displayName: Login - description: Username in remote system - example: Octocat - type: string - required: true - - get: - displayName: Find User - description: | - Retrieve a specific User by Login name - responses: - 200: - body: - application/json: - schema: !include schemas/user.json - 404: - description: | - Cannot find the User - - post: - displayName: Enable User - description: | - Enable a new User by Login name - responses: - 201: - body: - application/json: - schema: !include schemas/user.json - 400: - description: | - Error inserting User into the database - - patch: - displayName: Update User - description: | - Update a specific User - responses: - 200: - body: - application/json: - schema: !include schemas/user.json - 400: - description: | - Error updating User record in the database - - delete: - displayName: Delete User - description: | - Delete a specific User - responses: - 204: - description: | - Successfully deleted the User - 400: - description: | - Error deleting the User from the database - 403: - description: | - Cannot delete your own User account - 404: - description: | - Cannot find the User - - -# -# Badge Endpoint -# - -/badges/{owner}/{name}: - displayName: Badges - uriParameters: - owner: - displayName: Owner - description: Owner of the repository - type: string - required: true - name: - displayName: Name - description: Name of the repository - type: string - required: true - - /status.svg: - description: Returns an SVG status badge for the latest Build - displayName: Status Badge - get: - queryParameters: - branch: - default: master - required: false - type: string - example: master - responses: - 200: - body: - image/svg+xml: - - /cc.xml: - description: Returns a CCMenu feed for the Repository - displayName: CCMenu - get: - responses: - 200: - body: - application/xml: - example: !include samples/ccmenu.xml diff --git a/static/styles/pages/docs.sass b/static/styles/pages/docs.sass new file mode 100644 index 000000000..fa7806690 --- /dev/null +++ b/static/styles/pages/docs.sass @@ -0,0 +1,202 @@ + +.toc + list-style-type: none; + padding:0px; + margin:0px; + padding-bottom:40px; + + h2 + font-size:21px; + font-weight:normal; + margin-bottom:20px; + color:#2b303b; + + ul + list-style-type: none; + padding:0px; + margin:0px; + margin-bottom:40px; + border-bottom: 1px solid #EEE; + padding-bottom:40px; + li + line-height:25px; + a + color: #2b303b; + text-decoration:none; + a:hover + text-decoration:underline; + + [data-method]:before + content: attr(data-method); + padding:0px 10px; + line-height:18px; + min-width:70px; + font-size:11px; + text-transform:uppercase; + display: inline-block; + text-align:center; + color:#FFF; + border-radius:2px; + margin-right:20px; + + [data-method="GET"]:before + background-color:#1ABC9C; + [data-method="PUT"]:before + background-color:#9B59B6; + [data-method="POST"]:before + background-color:#3498DB; + [data-method="PATCH"]:before + background-color:#E67E22; + [data-method="DELETE"]:before + background-color:#E74C3C; + + + + [data-method]:before + background:#FFF; + border:1px solid #FFF; + [data-method="GET"]:before + color:#1ABC9C; + border-color:#1ABC9C; + [data-method="PUT"]:before + color:#9B59B6; + border-color:#9B59B6; + [data-method="POST"]:before + color:#3498DB; + border-color:#3498DB; + [data-method="PATCH"]:before + color:#E67E22; + border-color:#E67E22; + [data-method="DELETE"]:before + color:#E74C3C; + border-color:#E74C3C; + +.operation + + [data-method]:before + content: attr(data-method); + padding:0px 10px; + line-height:18px; + min-width:70px; + font-size:11px; + text-transform:uppercase; + display: inline-block; + text-align:center; + color:#FFF; + border-radius:2px; + margin-right:20px; + background:#FFF; + border:1px solid #FFF; + line-height: 20px; + vertical-align: top; + + + [data-method]:before + background:#FFF; + border:1px solid #FFF; + [data-method="GET"]:before + color:#1ABC9C; + border-color:#1ABC9C; + [data-method="PUT"]:before + color:#9B59B6; + border-color:#9B59B6; + [data-method="POST"]:before + color:#3498DB; + border-color:#3498DB; + [data-method="PATCH"]:before + color:#E67E22; + border-color:#E67E22; + [data-method="DELETE"]:before + color:#E74C3C; + border-color:#E74C3C; + +.docs + margin-top:40px; + padding: 0px 50px; + padding-right:40px; + pre + margin-right: 15px; + font-size: 14px; + color: #eff1f5; + color: #2b303b; + border-radius: 2px; + background: #2b303b; + background: #ECF0F1; + white-space: pre-wrap; + word-wrap: break-word; + box-sizing: border-box; + padding: 25px 30px; + font-family: "Roboto Mono"; + + +.operation + min-height:100vh; + padding:20px 0px; + display: flex + &> aside, + &> div + min-width:50%; + max-width:50%; + width:50%; + padding-right:40px; + h2 + color:#2b303b; + font-size:21px; + h3 + font-size:16px; + margin-bottom:20px; + margin-top:40px; + aside + background: rgba(43, 48, 59, 0.95); + box-sizing: border-box; + padding: 20px 0px 10px 0px; + border-radius:2px; + h4 + color: #d0d4d7; + font-size:15px; + padding:20px; + padding-left:40px; + pre + background: #2b303b; + color: #d0d4d7; + margin-right:0px; + padding-left:40px; + + .params + padding: 0px; + margin: 0px; + list-style-type: none; + border-top: 1px solid #f0f4f7 + li + padding:15px 10px; + border-bottom:1px solid #f0f4f7; + font-size:15px + p + line-height:20px; + margin: 0 0 0 170px; + + li:after + visibility: hidden; + display: block; + font-size: 0; + content: " "; + clear: both; + height: 0; + + h4 + float:left; + line-height:20px; + text-align:right; + padding-right:20px; + width:150px; + font-weight:bold; + font-size:15px; + + small + display:block; + text-transform: uppercase; + color:#E67E22; + font-size:11px; + font-weight:normal; + margin-top:5px; + diff --git a/static/styles/style.sass b/static/styles/style.sass index 079efbcef..0c10a1a77 100644 --- a/static/styles/style.sass +++ b/static/styles/style.sass @@ -15,6 +15,7 @@ @import pages/repo.sass @import pages/login.sass @import pages/feed.sass +@import pages/docs.sass @import header @import search diff --git a/static/styles_gen/style.css b/static/styles_gen/style.css index 0dcfc78bc..c8d865600 100644 --- a/static/styles_gen/style.css +++ b/static/styles_gen/style.css @@ -289,6 +289,86 @@ body.login div.alert { position: fixed; top: 0px; left: 0px; right: 0px; line-he .repo-row .card-header { background: #FFF; border-bottom: none; padding-right: 0px; padding-left: 0px; width: 45px; } +.toc { list-style-type: none; padding: 0px; margin: 0px; padding-bottom: 40px; } + +.toc h2 { font-size: 21px; font-weight: normal; margin-bottom: 20px; color: #2b303b; } + +.toc ul { list-style-type: none; padding: 0px; margin: 0px; margin-bottom: 40px; border-bottom: 1px solid #EEE; padding-bottom: 40px; } + +.toc ul li { line-height: 25px; } + +.toc ul li a { color: #2b303b; text-decoration: none; } + +.toc ul li a:hover { text-decoration: underline; } + +.toc [data-method]:before { content: attr(data-method); padding: 0px 10px; line-height: 18px; min-width: 70px; font-size: 11px; text-transform: uppercase; display: inline-block; text-align: center; color: #FFF; border-radius: 2px; margin-right: 20px; } + +.toc [data-method="GET"]:before { background-color: #1ABC9C; } + +.toc [data-method="PUT"]:before { background-color: #9B59B6; } + +.toc [data-method="POST"]:before { background-color: #3498DB; } + +.toc [data-method="PATCH"]:before { background-color: #E67E22; } + +.toc [data-method="DELETE"]:before { background-color: #E74C3C; } + +.toc [data-method]:before { background: #FFF; border: 1px solid #FFF; } + +.toc [data-method="GET"]:before { color: #1ABC9C; border-color: #1ABC9C; } + +.toc [data-method="PUT"]:before { color: #9B59B6; border-color: #9B59B6; } + +.toc [data-method="POST"]:before { color: #3498DB; border-color: #3498DB; } + +.toc [data-method="PATCH"]:before { color: #E67E22; border-color: #E67E22; } + +.toc [data-method="DELETE"]:before { color: #E74C3C; border-color: #E74C3C; } + +.operation [data-method]:before { content: attr(data-method); padding: 0px 10px; line-height: 18px; min-width: 70px; font-size: 11px; text-transform: uppercase; display: inline-block; text-align: center; color: #FFF; border-radius: 2px; margin-right: 20px; background: #FFF; border: 1px solid #FFF; line-height: 20px; vertical-align: top; } + +.operation [data-method]:before { background: #FFF; border: 1px solid #FFF; } + +.operation [data-method="GET"]:before { color: #1ABC9C; border-color: #1ABC9C; } + +.operation [data-method="PUT"]:before { color: #9B59B6; border-color: #9B59B6; } + +.operation [data-method="POST"]:before { color: #3498DB; border-color: #3498DB; } + +.operation [data-method="PATCH"]:before { color: #E67E22; border-color: #E67E22; } + +.operation [data-method="DELETE"]:before { color: #E74C3C; border-color: #E74C3C; } + +.docs { margin-top: 40px; padding: 0px 50px; padding-right: 40px; } + +.docs pre { margin-right: 15px; font-size: 14px; color: #eff1f5; color: #2b303b; border-radius: 2px; background: #2b303b; background: #ECF0F1; white-space: pre-wrap; word-wrap: break-word; box-sizing: border-box; padding: 25px 30px; font-family: "Roboto Mono"; } + +.operation { min-height: 100vh; padding: 20px 0px; display: flex; } + +.operation > aside, .operation > div { min-width: 50%; max-width: 50%; width: 50%; padding-right: 40px; } + +.operation h2 { color: #2b303b; font-size: 21px; } + +.operation h3 { font-size: 16px; margin-bottom: 20px; margin-top: 40px; } + +.operation aside { background: rgba(43, 48, 59, 0.95); box-sizing: border-box; padding: 20px 0px 10px 0px; border-radius: 2px; } + +.operation aside h4 { color: #d0d4d7; font-size: 15px; padding: 20px; padding-left: 40px; } + +.operation aside pre { background: #2b303b; color: #d0d4d7; margin-right: 0px; padding-left: 40px; } + +.operation .params { padding: 0px; margin: 0px; list-style-type: none; border-top: 1px solid #f0f4f7; } + +.operation .params li { padding: 15px 10px; border-bottom: 1px solid #f0f4f7; font-size: 15px; } + +.operation .params li p { line-height: 20px; margin: 0 0 0 170px; } + +.operation .params li:after { visibility: hidden; display: block; font-size: 0; content: " "; clear: both; height: 0; } + +.operation .params h4 { float: left; line-height: 20px; text-align: right; padding-right: 20px; width: 150px; font-weight: bold; font-size: 15px; } + +.operation .params small { display: block; text-transform: uppercase; color: #E67E22; font-size: 11px; font-weight: normal; margin-top: 5px; } + .tt-open { position: absolute; top: 34px; left: 0px; z-index: 100; display: none; background: #FFF; min-width: 100%; border: 1px solid #eee; border-radius: 0px; } .tt-selectable:hover, .tt-cursor { background: #f4f4f4; } diff --git a/template/amber/swagger.amber b/template/amber/swagger.amber new file mode 100644 index 000000000..012cadebf --- /dev/null +++ b/template/amber/swagger.amber @@ -0,0 +1,65 @@ +extends base + +block append head + title API ยท Drone + +block header + ol + li Documentation + ul.nav.nav-tabs + li.nav-item + a.nav-link[href="#"] Install + li.nav-item + a.nav-link[href="#"] Builds + li.nav-item + a.nav-link[href="#"] Plugins + li.nav-item + a.nav-link.active[href="#"] API Reference + +block content + div.container-fluid.docs.docs-api + a[name="top"] + div.row + ul.toc + each $tag in Swagger.Tags + li + h2 #{$tag.Name} + ul + each $op in $tag.Ops + li + a[href="#"+$op.ID][data-method=$op.Method] #{$op.Path} + div.row + each $tag in Swagger.Tags + each $op in $tag.Ops + a[name=$op.ID] + div.operation + div + h2[data-method=$op.Method] #{$op.Summary} + p #{$op.Desc} + + h3 Request Parameters + ul.params + each $param in $op.Params + li + h4 + | #{$param.Name} + small Required + p #{$param.Desc} + + h3 Response Messages + ul.params + each $result in $op.Results + li + h4 + | #{$result.Status} + p #{$result.Desc} + aside + h4 Endpoint + pre #{$op.Method} #{$op.Path} + each $res in $op.Results + if $res.Example + h4 Example Response + if $res.IsArray + pre [#{$res.Example}] + else if $res.IsObject + pre #{$res.Example} \ No newline at end of file