diff --git a/.gitignore b/.gitignore index c74a9f8..49fa6ca 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ #internal/dist/openapi.yaml testing_out.yaml ./testing_out.yaml -internal/dist/openapi.yaml \ No newline at end of file +internal/dist/openapi.yaml +.vscode \ No newline at end of file diff --git a/README.md b/README.md index cb1cf2e..815b19f 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,9 @@ other parts of the system for that matter. To run examples, and checkout hosted documentation via Swagger UI, issue the following command: ```sh -$ go run ./examples/*.go +$ go run ./examples/file_output/*.go +# or uncomment line 40 and comment line 38 in internal/dist/index.html before running: +$ go run ./examples/stream_output/*.go ``` And navigate to `http://localhost:3005/docs/api/` in case that you didn't change anything before running the example diff --git a/build.go b/build.go index e4a8449..8c86776 100644 --- a/build.go +++ b/build.go @@ -38,7 +38,7 @@ func getPathFromFirstElement(cbs []ConfigBuilder) string { func (o *OAS) BuildDocs(conf ...ConfigBuilder) error { o.initCallStackForRoutes() - yml, err := marshalToYAML(o) + yml, err := o.marshalToYAML() if err != nil { return fmt.Errorf("marshaling issue occurred: %w", err) } @@ -51,7 +51,23 @@ func (o *OAS) BuildDocs(conf ...ConfigBuilder) error { return nil } -func marshalToYAML(oas *OAS) ([]byte, error) { +// BuildStream marshals the OAS struct to YAML and writes it to a stream. +// +// Returns an error if there is any. +func (o *OAS) BuildStream(w io.Writer) error { + yml, err := o.marshalToYAML() + if err != nil { + return fmt.Errorf("marshaling issue occurred: %w", err) + } + + err = writeAndFlush(yml, w) + if err != nil { + return fmt.Errorf("writing issue occurred: %w", err) + } + return nil +} + +func (oas *OAS) marshalToYAML() ([]byte, error) { transformedOAS := oas.transformToHybridOAS() yml, err := yaml.Marshal(transformedOAS) diff --git a/build_test.go b/build_test.go index 1715711..a3e28ee 100644 --- a/build_test.go +++ b/build_test.go @@ -1,6 +1,9 @@ package docs -import "testing" +import ( + "bytes" + "testing" +) func TestUnitBuild(t *testing.T) { t.Parallel() @@ -109,3 +112,124 @@ func TestUnitGetPathFromFirstElem(t *testing.T) { } // QUICK CHECK TESTS ARE COMING WITH NEXT RELEASE. + +func TestOAS_BuildStream(t *testing.T) { + tests := []struct { + name string + oas *OAS + wantW string + wantErr bool + }{ + { + name: "success", + oas: &OAS{ + OASVersion: "3.0.1", + Info: Info{ + Title: "Test", + Description: "Test object", + }, + Components: Components{ + Component{ + Schemas: Schemas{Schema{ + Name: "schema_testing", + Properties: SchemaProperties{ + SchemaProperty{ + Name: "EnumProp", + Type: "enum", + Description: "short desc", + Enum: []string{"enum", "test", "strSlc"}, + }, + SchemaProperty{ + Name: "intProp", + Type: "integer", + Format: "int64", + Description: "short desc", + Default: 1337, + }, + }, + XML: XMLEntry{Name: "XML entry test"}, + }}, + SecuritySchemes: SecuritySchemes{SecurityScheme{ + Name: "ses_scheme_testing", + In: "not empty", + Flows: SecurityFlows{SecurityFlow{ + Type: "implicit", + AuthURL: "http://petstore.swagger.io/oauth/dialog", + Scopes: SecurityScopes{ + SecurityScope{ + Name: "write:pets", + Description: "Write to Pets", + }, + SecurityScope{ + Name: "read:pets", + Description: "Read Pets", + }, + }, + }}, + }}, + }, + }, + }, + wantErr: false, + wantW: `openapi: 3.0.1 +info: + title: Test + description: Test object + termsOfService: "" + contact: + email: "" + license: + name: "" + url: "" + version: "" +externalDocs: + description: "" + url: "" +servers: [] +tags: [] +paths: {} +components: + schemas: + schema_testing: + $ref: "" + properties: + EnumProp: + description: short desc + enum: + - enum + - test + - strSlc + type: enum + intProp: + default: 1337 + description: short desc + format: int64 + type: integer + type: "" + xml: + name: XML entry test + securitySchemes: + ses_scheme_testing: + flows: + implicit: + authorizationUrl: http://petstore.swagger.io/oauth/dialog + scopes: + read:pets: Read Pets + write:pets: Write to Pets + in: not empty +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &bytes.Buffer{} + if err := tt.oas.BuildStream(w); (err != nil) != tt.wantErr { + t.Errorf("OAS.BuildStream() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotW := w.String(); gotW != tt.wantW { + t.Errorf("OAS.BuildStream() = [%v], want {%v}", gotW, tt.wantW) + } + }) + } +} diff --git a/examples/api-documentation.go b/examples/file_output/api-documentation.go similarity index 100% rename from examples/api-documentation.go rename to examples/file_output/api-documentation.go diff --git a/examples/main.go b/examples/file_output/main.go similarity index 100% rename from examples/main.go rename to examples/file_output/main.go diff --git a/examples/shared-resources.go b/examples/file_output/shared-resources.go similarity index 100% rename from examples/shared-resources.go rename to examples/file_output/shared-resources.go diff --git a/examples/users_example/get-user.go b/examples/file_output/users_example/get-user.go similarity index 100% rename from examples/users_example/get-user.go rename to examples/file_output/users_example/get-user.go diff --git a/examples/users_example/post-user.go b/examples/file_output/users_example/post-user.go similarity index 100% rename from examples/users_example/post-user.go rename to examples/file_output/users_example/post-user.go diff --git a/examples/users_example/service.go b/examples/file_output/users_example/service.go similarity index 100% rename from examples/users_example/service.go rename to examples/file_output/users_example/service.go diff --git a/examples/stream_output/logging.go b/examples/stream_output/logging.go new file mode 100644 index 0000000..379ad30 --- /dev/null +++ b/examples/stream_output/logging.go @@ -0,0 +1,47 @@ +package main + +import ( + "log" + "net/http" + "time" +) + +type responseData struct { + status int + size int +} + +// our http.ResponseWriter implementation +type loggingResponseWriter struct { + http.ResponseWriter // compose original http.ResponseWriter + responseData *responseData +} + +func (r *loggingResponseWriter) Write(b []byte) (int, error) { + size, err := r.ResponseWriter.Write(b) // write response using original http.ResponseWriter + r.responseData.size += size // capture size + return size, err +} + +func (r *loggingResponseWriter) WriteHeader(statusCode int) { + r.ResponseWriter.WriteHeader(statusCode) // write status code using original http.ResponseWriter + r.responseData.status = statusCode // capture status code +} + +func LogginMiddleware(h http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + var ( + start = time.Now() + responseData = &responseData{} + ) + + h.ServeHTTP(&loggingResponseWriter{ + ResponseWriter: rw, + responseData: responseData, + }, req) + + duration := time.Since(start) + + log.Printf("%s[%v] uri:%s duration:%v size:%d", req.Method, responseData.status, req.RequestURI, duration, responseData.size) + }) +} diff --git a/examples/stream_output/main.go b/examples/stream_output/main.go new file mode 100644 index 0000000..ca858fc --- /dev/null +++ b/examples/stream_output/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "errors" + "fmt" + "net/http" + "os" + + "github.com/go-oas/docs" +) + +const ( + staticRoute = "/docs/api/" + streamRoute = "/docs/oas/" + staticDirectory = "./internal/dist" + port = 3005 +) + +func main() { + apiDoc := docs.New() + apiSetInfo(&apiDoc) + apiSetTags(&apiDoc) + apiSetServers(&apiDoc) + apiSetExternalDocs(&apiDoc) + apiSetComponents(&apiDoc) + + apiDoc.AddRoute(docs.Path{ + Route: "/users", + HTTPMethod: "POST", + OperationID: "createUser", + Summary: "Create a new User", + Responses: docs.Responses{ + getResponseOK(), + getResponseNotFound(), + }, + // HandlerFuncName: "handleCreateUser", + RequestBody: docs.RequestBody{ + Description: "Create a new User", + Content: docs.ContentTypes{ + getContentApplicationJSON("#/components/schemas/User"), + }, + Required: true, + }, + }) + + apiDoc.AddRoute(docs.Path{ + Route: "/users", + HTTPMethod: "GET", + OperationID: "getUser", + Summary: "Get a User", + Responses: docs.Responses{ + getResponseOK(), + }, + // HandlerFuncName: "handleCreateUser", + RequestBody: docs.RequestBody{ + Description: "Get a user", + Content: docs.ContentTypes{ + getContentApplicationJSON("#/components/schemas/User"), + }, + Required: true, + }, + }) + + mux := http.NewServeMux() + + // serve static files + fs := http.FileServer(http.Dir(staticDirectory)) + mux.Handle(staticRoute, http.StripPrefix(staticRoute, fs)) + + // serve the oas document from a stream + mux.HandleFunc(streamRoute, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/yaml") + if err := apiDoc.BuildStream(w); err != nil { + http.Error(w, "could not write body", http.StatusInternalServerError) + return + } + }) + + fmt.Printf("Listening at :%d", port) + if err := http.ListenAndServe(fmt.Sprintf(":%d", port), LogginMiddleware(mux)); err != nil { + if errors.Is(err, http.ErrServerClosed) { + fmt.Printf("server closed\n") + } else if err != nil { + fmt.Printf("error starting server: %s\n", err) + os.Exit(1) + } + } +} diff --git a/examples/stream_output/oas-setting.go b/examples/stream_output/oas-setting.go new file mode 100644 index 0000000..e662c08 --- /dev/null +++ b/examples/stream_output/oas-setting.go @@ -0,0 +1,171 @@ +package main + +import "github.com/go-oas/docs" + +func apiSetInfo(apiDoc *docs.OAS) { + apiDoc.SetOASVersion("3.0.1") + apiInfo := apiDoc.GetInfo() + apiInfo.Title = "Build OAS3.0.1" + apiInfo.Description = "Builder Testing for OAS3.0.1" + apiInfo.TermsOfService = "https://smartbear.com/terms-of-use/" + apiInfo.SetContact("padiazg@gmail.com") // mixed usage of setters -> + apiInfo.License = docs.License{ // and direct struct usage. + Name: "MIT", + URL: "https://github.com/go-oas/docs/blob/main/LICENSE", + } + apiInfo.Version = "1.0.1" +} + +func apiSetTags(apiDoc *docs.OAS) { + // With Tags example you can see usage of direct struct modifications, setter and appender as well. + apiDoc.Tags = docs.Tags{ + docs.Tag{ + Name: "user", + Description: "Operations about the User", + ExternalDocs: docs.ExternalDocs{ + Description: "User from the Petstore example", + URL: "http://swagger.io", + }, + }, + } + apiDoc.Tags.SetTag( + "pet", + "Everything about your Pets", + docs.ExternalDocs{ + Description: "Find out more about our store (Swagger UI Example)", + URL: "http://swagger.io", + }, + ) + + newTag := &docs.Tag{ + Name: "petko", + Description: "Everything about your Petko", + ExternalDocs: docs.ExternalDocs{ + Description: "Find out more about our store (Swagger UI Example)", + URL: "http://swagger.io", + }, + } + apiDoc.Tags.AppendTag(newTag) +} + +func apiSetServers(apiDoc *docs.OAS) { + apiDoc.Servers = docs.Servers{ + docs.Server{ + URL: "https://petstore.swagger.io/v2", + }, + docs.Server{ + URL: "http://petstore.swagger.io/v2", + }, + } +} + +func apiSetExternalDocs(apiDoc *docs.OAS) { + apiDoc.ExternalDocs = docs.ExternalDocs{ + Description: "External documentation", + URL: "https://kaynetik.com", + } +} + +func apiSetComponents(apiDoc *docs.OAS) { + apiDoc.Components = docs.Components{ + docs.Component{ + Schemas: docs.Schemas{ + docs.Schema{ + Name: "User", + Type: "object", + Properties: docs.SchemaProperties{ + docs.SchemaProperty{ + Name: "id", + Type: "integer", + Format: "int64", + Description: "UserID", + }, + docs.SchemaProperty{ + Name: "username", + Type: "string", + }, + docs.SchemaProperty{ + Name: "email", + Type: "string", + }, + docs.SchemaProperty{ + Name: "userStatus", + Type: "integer", + Description: "User Status", + Format: "int32", + }, + docs.SchemaProperty{ + Name: "phForEnums", + Type: "enum", + Enum: []string{"placed", "approved"}, + }, + }, + XML: docs.XMLEntry{Name: "User"}, + }, + docs.Schema{ + Name: "Tag", + Type: "object", + Properties: docs.SchemaProperties{ + docs.SchemaProperty{ + Name: "id", + Type: "integer", + Format: "int64", + }, + docs.SchemaProperty{ + Name: "name", + Type: "string", + }, + }, + XML: docs.XMLEntry{Name: "Tag"}, + }, + docs.Schema{ + Name: "ApiResponse", + Type: "object", + Properties: docs.SchemaProperties{ + docs.SchemaProperty{ + Name: "code", + Type: "integer", + Format: "int32", + }, + docs.SchemaProperty{ + Name: "type", + Type: "string", + }, + docs.SchemaProperty{ + Name: "message", + Type: "string", + }, + }, + XML: docs.XMLEntry{Name: "ApiResponse"}, + }, + }, + SecuritySchemes: docs.SecuritySchemes{ + docs.SecurityScheme{ + Name: "api_key", + Type: "apiKey", + In: "header", + }, + docs.SecurityScheme{ + Name: "petstore_auth", + Type: "oauth2", + Flows: docs.SecurityFlows{ + docs.SecurityFlow{ + Type: "implicit", + AuthURL: "http://petstore.swagger.io/oauth/dialog", + Scopes: docs.SecurityScopes{ + docs.SecurityScope{ + Name: "write:users", + Description: "Modify users", + }, + docs.SecurityScope{ + Name: "read:users", + Description: "Read users", + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/examples/stream_output/readme.md b/examples/stream_output/readme.md new file mode 100644 index 0000000..722f7b0 --- /dev/null +++ b/examples/stream_output/readme.md @@ -0,0 +1,17 @@ +# Stream output example +This example shows how to use the `BuildStream` function to generate the document into a stream. You can use this stream to send the document to any destination that accepts a stream, like an HTTP response or a file. + +# Annotations +The example also shows that now we can add routes without parsing the code looking for annotations. This feature can be helpful in several use cases, like generating the documentation from a framework, or some definition or manifest because you don't have access to code to write annotations. + +## Update index.html +This example serves the document in the `/docs/oas`, no file is generated, and the renderer in `/docs/api`. To correctly render the document you must uncomment line 40 and make sure lines 38 and 39 are commented in `internal/dist/index.html` + +```html + ... + window.ui = SwaggerUIBundle({ + // url: "openapi.yaml", + // url: "https://petstore.swagger.io/v2/swagger.json", + url: "/docs/oas", + ... +``` diff --git a/examples/stream_output/shared-resources.go b/examples/stream_output/shared-resources.go new file mode 100644 index 0000000..6251011 --- /dev/null +++ b/examples/stream_output/shared-resources.go @@ -0,0 +1,34 @@ +package main + +import "github.com/go-oas/docs" + +// Args packer could be used to improve readability of such functions, +// and to make the more flexible in order to avoid empty string comparisons. +func getResponseOK(args ...interface{}) docs.Response { + description := "OK" + if args != nil { + description = args[0].(string) + } + + return docs.Response{ + Code: 200, + Description: description, + } +} + +func getResponseNotFound() docs.Response { + return docs.Response{ + Code: 404, + Description: "Not Found", + Content: docs.ContentTypes{ + getContentApplicationJSON("#/components/schemas/User"), + }, + } +} + +func getContentApplicationJSON(refSchema string) docs.ContentType { + return docs.ContentType{ + Name: "application/json", + Schema: refSchema, + } +} diff --git a/internal/dist/index.html b/internal/dist/index.html index 27feb1a..9013c6d 100644 --- a/internal/dist/index.html +++ b/internal/dist/index.html @@ -37,6 +37,7 @@ window.ui = SwaggerUIBundle({ url: "openapi.yaml", // url: "https://petstore.swagger.io/v2/swagger.json", + // url: "/docs/oas", dom_id: '#swagger-ui', deepLinking: true, presets: [ diff --git a/routing.go b/routing.go index 21eb337..cf1ad97 100644 --- a/routing.go +++ b/routing.go @@ -38,3 +38,9 @@ func (o *OAS) GetRegisteredRoutes() RegRoutes { func (o *OAS) GetPathByIndex(index int) *Path { return &o.Paths[index] } + +// AddRoute is used for add API documentation routes. +// +func (o *OAS) AddRoute(path Path) { + o.Paths = append(o.Paths, path) +} diff --git a/routing_test.go b/routing_test.go index 2497d47..91504c5 100644 --- a/routing_test.go +++ b/routing_test.go @@ -268,3 +268,66 @@ func TestQuickUnitAttachRoutes(t *testing.T) { t.Errorf("Check failed: %#v", err) } } + +func TestOAS_AddRoute(t *testing.T) { + + var ( + respose200 = Response{Code: 200, Description: "Ok"} + respose404 = Response{Code: 404, Description: "Not Found"} + contentTypeUser = ContentType{Name: "application/json", Schema: "#/components/schemas/User"} + requestBodyGetUser = RequestBody{ + Description: "Get a User", + Content: ContentTypes{contentTypeUser}, + Required: true, + } + requestBodyCreateUser = RequestBody{ + Description: "Create a new User", + Content: ContentTypes{contentTypeUser}, + Required: true, + } + pathGetUser = Path{ + Route: "/users", + HTTPMethod: "GET", + OperationID: "getUser", + Summary: "Get a User", + Responses: Responses{respose200}, + RequestBody: requestBodyGetUser, + } + pathCreateUser = Path{ + Route: "/users", + HTTPMethod: "POST", + OperationID: "createUser", + Summary: "Create a new User", + Responses: Responses{respose200, respose404}, + RequestBody: requestBodyCreateUser, + } + ) + + tests := []struct { + name string + oas *OAS + path Path + wantPaths Paths + }{ + { + name: "success-no-existing-paths", + oas: &OAS{}, + path: pathGetUser, + wantPaths: Paths{pathGetUser}, + }, + { + name: "success-existing-paths", + oas: &OAS{Paths: Paths{pathGetUser}}, + path: pathCreateUser, + wantPaths: Paths{pathGetUser, pathCreateUser}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.oas.AddRoute(tt.path) + if !reflect.DeepEqual(tt.wantPaths, tt.oas.Paths) { + t.Errorf("OAS.AddRoute() = [%v], want {%v}", tt.oas.Paths, tt.wantPaths) + } + }) + } +}