diff --git a/cmd/nirvana/api/api.go b/cmd/nirvana/api/api.go index 6b46e4c4..1964e3ad 100644 --- a/cmd/nirvana/api/api.go +++ b/cmd/nirvana/api/api.go @@ -17,14 +17,6 @@ limitations under the License. package api import ( - "bytes" - "context" - "encoding/json" - "fmt" - "html/template" - "io/ioutil" - "os" - "path/filepath" "strconv" "strings" @@ -33,13 +25,13 @@ import ( "github.com/caicloud/nirvana/definition" "github.com/caicloud/nirvana/log" "github.com/caicloud/nirvana/service" - "github.com/caicloud/nirvana/utils/generators/swagger" + "github.com/caicloud/nirvana/utils/api" + generatorsutils "github.com/caicloud/nirvana/utils/generators/utils" + "github.com/spf13/cobra" "github.com/spf13/pflag" ) -const mimeHTML = "text/html" - func init() { err := service.RegisterProducer(service.NewSimpleSerializer("text/html")) if err != nil { @@ -94,24 +86,13 @@ func (o *apiOptions) Run(cmd *cobra.Command, args []string) error { log.Infof("Project root directory is %s", config.Root) - generator := swagger.NewDefaultGenerator(config, definitions) - swaggers, err := generator.Generate() + files, err := generatorsutils.GenSwaggerData(config, definitions) if err != nil { return err } - files := map[string][]byte{} - for filename, s := range swaggers { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err - } - - files[filename] = data - } - if o.Output != "" { - if err = o.write(files); err != nil { + if err = api.WriteFiles(o.Output, files); err != nil { return err } } @@ -122,25 +103,6 @@ func (o *apiOptions) Run(cmd *cobra.Command, args []string) error { return err } -func (o *apiOptions) write(apis map[string][]byte) error { - dir := o.Output - dir, err := filepath.Abs(dir) - if err != nil { - return err - } - if err := os.MkdirAll(dir, 0775); err != nil { - return err - } - for version, data := range apis { - file := filepath.Join(dir, o.pathForVersion(version)) - if err := ioutil.WriteFile(file, data, 0664); err != nil { - return err - } - } - log.Infof("Generated openapi schemes to %s", dir) - return nil -} - func (o *apiOptions) serve(apis map[string][]byte) error { hosts := strings.Split(o.Serve, ":") ip := strings.TrimSpace(hosts[0]) @@ -160,19 +122,19 @@ func (o *apiOptions) serve(apis map[string][]byte) error { } log.SetDefaultLogger(log.NewStdLogger(0)) cfg := nirvana.NewDefaultConfig() - versions := []string{} + versions := make([]string, 0, len(apis)) for v, data := range apis { versions = append(versions, v) cfg.Configure(nirvana.Descriptor( - o.descriptorForData(o.pathForVersion(v), data, definition.MIMEJSON), + api.DescriptorForData(api.PathForVersion("/", v), data, definition.MIMEJSON), )) } - data, err := o.indexData(versions) + data, err := api.GenSwaggerPageData("/", versions) if err != nil { return err } cfg.Configure( - nirvana.Descriptor(o.descriptorForData("/", data, mimeHTML)), + nirvana.Descriptor(api.DescriptorForData("/", data, definition.MIMEHTML)), nirvana.IP(ip), nirvana.Port(port), ) @@ -180,114 +142,6 @@ func (o *apiOptions) serve(apis map[string][]byte) error { return nirvana.NewServer(cfg).Serve() } -func (o *apiOptions) descriptorForData(path string, data []byte, ct string) definition.Descriptor { - return definition.Descriptor{ - Path: path, - Definitions: []definition.Definition{ - { - Method: definition.Get, - Consumes: []string{definition.MIMENone}, - Produces: []string{ct}, - Function: func(context.Context) ([]byte, error) { - return data, nil - }, - Parameters: []definition.Parameter{}, - Results: definition.DataErrorResults(""), - }, - }, - } -} - -func (o *apiOptions) pathForVersion(version string) string { - return fmt.Sprintf("/api.%s.json", version) -} - -func (o *apiOptions) indexData(versions []string) ([]byte, error) { - index := ` - - - - - - Swagger UI - - - - - - -
- - - - - -` - tmpl, err := template.New("index.html").Parse(index) - if err != nil { - return nil, err - } - data := make([]struct { - Name string - Path string - }, len(versions)) - for i, v := range versions { - data[i].Name = v - data[i].Path = o.pathForVersion(v) - } - buf := bytes.NewBuffer(nil) - if err := tmpl.Execute(buf, data); err != nil { - return nil, err - } - return buf.Bytes(), nil -} - func (o *apiOptions) Manuals() string { return "" } diff --git a/definition/helper.go b/definition/helper.go index cb2bd6b6..55a63db7 100644 --- a/definition/helper.go +++ b/definition/helper.go @@ -24,7 +24,7 @@ import ( // MIME types const ( - // acceptTypeAll indicates a accept type from http request. + // MIMEAll indicates a accept type from http request. // It means client can receive any content. // Request content type in header "Content-Type" must not set to "*/*". // It only can exist in request header "Accept". @@ -33,6 +33,7 @@ const ( MIMEAll = "*/*" MIMENone = "" MIMEText = "text/plain" + MIMEHTML = "text/html" MIMEJSON = "application/json" MIMEXML = "application/xml" MIMEOctetStream = "application/octet-stream" diff --git a/examples/api-basic/main.go b/examples/api-basic/main.go index 43be0093..356b4f72 100644 --- a/examples/api-basic/main.go +++ b/examples/api-basic/main.go @@ -17,15 +17,21 @@ limitations under the License. package main import ( + "github.com/caicloud/nirvana" "github.com/caicloud/nirvana/config" - "github.com/caicloud/nirvana/examples/api-basic/api/v1" - "github.com/caicloud/nirvana/examples/api-basic/api/v2" + v1 "github.com/caicloud/nirvana/examples/api-basic/api/v1" + v2 "github.com/caicloud/nirvana/examples/api-basic/api/v2" "github.com/caicloud/nirvana/log" ) func main() { cmd := config.NewDefaultNirvanaCommand() - if err := cmd.Execute(v1.Descriptor(), v2.Descriptor()); err != nil { + cfg := nirvana.NewDefaultConfig() + cfg.Configure( + nirvana.Descriptor(v1.Descriptor(), v2.Descriptor()), + nirvana.APIDocs("github.com/caicloud/nirvana/examples/api-basic", "/docs"), + ) + if err := cmd.ExecuteWithConfig(cfg); err != nil { log.Fatal(err) } } diff --git a/examples/api-basic/nirvana.yaml b/examples/api-basic/nirvana.yaml new file mode 100644 index 00000000..f57d3f12 --- /dev/null +++ b/examples/api-basic/nirvana.yaml @@ -0,0 +1,23 @@ +project: myproject +description: This project uses nirvana as API framework +schemes: +- http +hosts: +- localhost:8080 +contacts: +- name: nobody + email: nobody@nobody.io + description: Maintain this project +versions: +- name: v1 + description: The v1 version is the first version of this project + rules: + - prefix: /api/v1 + regexp: "" + replacement: "" +- name: v2 + description: The v2 version is the first version of this project + rules: + - prefix: /api/v2 + regexp: "" + replacement: "" diff --git a/nirvana.go b/nirvana.go index 42e24e30..fee03ca1 100644 --- a/nirvana.go +++ b/nirvana.go @@ -20,6 +20,8 @@ import ( "context" "fmt" "net/http" + "os" + "path" "sync" "sync/atomic" @@ -27,10 +29,9 @@ import ( "github.com/caicloud/nirvana/errors" "github.com/caicloud/nirvana/log" "github.com/caicloud/nirvana/service" - - // This blank import will make it in the dependencies of projects using Nirvana - // for API docs generation. - _ "github.com/caicloud/nirvana/utils/api" + "github.com/caicloud/nirvana/utils/api" + generatorsutils "github.com/caicloud/nirvana/utils/generators/utils" + "github.com/caicloud/nirvana/utils/project" ) // Server is a complete API server. @@ -73,6 +74,13 @@ type Config struct { // locked is for locking current config. If the field // is not 0, any modification causes panic. locked int32 + + // options for generating API docs + + // root is the absolute path of the current project. + root string + // apiDocsPath is the path to the API documentation page, empty means do not serve the documentation page. + apiDocsPath string } // lock locks config. If succeed, it will return ture. @@ -203,6 +211,76 @@ func NewServer(c *Config) Server { var noConfigInstaller = errors.InternalServerError.Build("Nirvana:NoConfigInstaller", "no config installer for external config name ${name}") +func (s *server) buildAPIDocs(definitions *api.Definitions) (map[string][]byte, error) { + config, _ := project.LoadDefaultProjectFile(s.config.root) + if config == nil { + config = &project.Config{ + Root: s.config.root, + Project: "Unknown Project", + Description: "This project does not have a project config.", + Schemes: []string{"http"}, + Hosts: []string{"localhost"}, + Versions: []project.Version{ + { + Name: "unversioned", + PathRules: []project.PathRule{ + { + Prefix: "/", + }, + }, + }, + }, + } + log.Warning("can't find project file, instead by default config") + } + return generatorsutils.GenSwaggerData(config, definitions) +} + +func (s *server) serveAPIDocs(builder service.Builder) error { + typeContainer := api.NewTypeContainer() + analyzer, err := api.NewAnalyzer(s.config.root, "./...") + if err != nil { + return err + } + result, err := api.NewPathDefinitions(typeContainer, builder.Definitions()) + if err != nil { + return err + } + if err = typeContainer.Complete(analyzer); err != nil { + return err + } + definitions := &api.Definitions{ + Definitions: result, + Types: typeContainer.Types(), + } + files, err := s.buildAPIDocs(definitions) + if err != nil { + return err + } + + // serve the API docs page + if s.config.apiDocsPath != "" { + apiDescriptor := make([]definition.Descriptor, 0, len(files)+1) + versions := make([]string, 0, len(files)) + for v, data := range files { + versions = append(versions, v) + apiDescriptor = append(apiDescriptor, api.DescriptorForData(api.PathForVersion(s.config.apiDocsPath, v), data, definition.MIMEJSON)) + } + data, err := api.GenSwaggerPageData(s.config.apiDocsPath, versions) + if err != nil { + return err + } + apiDescriptor = append( + apiDescriptor, + api.DescriptorForData(s.config.apiDocsPath, data, definition.MIMEHTML), + ) + if err = builder.AddDescriptor(apiDescriptor...); err != nil { + return err + } + } + return nil +} + // Builder create a service builder for current server. Don't use this method directly except // there is a special server to hold http services. After server shutdown, clean resources via // returned cleaner. @@ -221,6 +299,13 @@ func (s *server) Builder() (builder service.Builder, cleaner func() error, err e if err := builder.AddDescriptor(s.config.descriptors...); err != nil { return nil, nil, err } + + if s.config.root != "" { + if err := s.serveAPIDocs(builder); err != nil { + return nil, nil, err + } + } + if err := s.config.forEach(func(name string, config interface{}) error { installer := ConfigInstallerFor(name) if installer == nil { @@ -388,3 +473,24 @@ func Modifier(modifiers ...service.DefinitionModifier) Configurer { return nil } } + +// APIDocs returns a configurer to set API docs generation related options. +func APIDocs(modulePath, apiDocsPath string) Configurer { + return func(c *Config) error { + if modulePath == "" { + return fmt.Errorf("the module path can't be empty") + } + var err error + root := path.Join(os.Getenv("GOPATH"), "src", modulePath) + s, _ := os.Stat(root) + if s == nil || !s.IsDir() { + root, err = os.Getwd() + if err != nil { + return err + } + } + c.root = root + c.apiDocsPath = apiDocsPath + return nil + } +} diff --git a/service/builder.go b/service/builder.go index a799d7eb..42009620 100644 --- a/service/builder.go +++ b/service/builder.go @@ -38,9 +38,9 @@ type Builder interface { SetModifier(m DefinitionModifier) // Filters returns all request filters. Filters() []Filter - // AddFilters add filters to filter requests. + // AddFilter add filters to filter requests. AddFilter(filters ...Filter) - // AddDescriptors adds descriptors to router. + // AddDescriptor adds descriptors to router. AddDescriptor(descriptors ...definition.Descriptor) error // Middlewares returns all router middlewares. Middlewares() map[string][]definition.Middleware @@ -77,7 +77,7 @@ func (b *builder) Filters() []Filter { return result } -// AddFilters add filters to filter requests. +// AddFilter add filters to filter requests. func (b *builder) AddFilter(filters ...Filter) { b.filters = append(b.filters, filters...) } diff --git a/service/content.go b/service/content.go index 5b473448..524dbfda 100644 --- a/service/content.go +++ b/service/content.go @@ -62,6 +62,7 @@ var producers = map[string]Producer{ definition.MIMEJSON: &JSONSerializer{}, definition.MIMEXML: &XMLSerializer{}, definition.MIMEOctetStream: NewSimpleSerializer(definition.MIMEOctetStream), + definition.MIMEHTML: NewSimpleSerializer(definition.MIMEHTML), } // AllConsumers returns all consumers. diff --git a/utils/api/utils.go b/utils/api/utils.go new file mode 100644 index 00000000..30ccb49a --- /dev/null +++ b/utils/api/utils.go @@ -0,0 +1,161 @@ +/* +Copyright 2020 Caicloud Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package api + +import ( + "bytes" + "context" + "fmt" + "html/template" + "io/ioutil" + "os" + "path" + "path/filepath" + + "github.com/caicloud/nirvana/definition" +) + +// PathForVersion returns the path of API file. +func PathForVersion(root, version string) string { + return path.Join(root, fmt.Sprintf("/api.%s.json", version)) +} + +// WriteFiles writes the API data into files. +func WriteFiles(output string, apis map[string][]byte) error { + dir, err := filepath.Abs(output) + if err != nil { + return err + } + if err = os.MkdirAll(dir, 0775); err != nil { + return err + } + for version, data := range apis { + file := filepath.Join(dir, fmt.Sprintf("api.%s.json", version)) + if err = ioutil.WriteFile(file, data, 0664); err != nil { + return err + } + } + return nil +} + +// GenSwaggerPageData generates a Swagger UI page based on the given API files. +func GenSwaggerPageData(root string, versions []string) ([]byte, error) { + index := ` + + + + + + Swagger UI + + + + + + +
+ + + + + +` + tmpl, err := template.New("index.html").Parse(index) + if err != nil { + return nil, err + } + data := make([]struct { + Name string + Path string + }, 0, len(versions)) + for _, v := range versions { + data = append(data, struct { + Name string + Path string + }{v, PathForVersion(root, v)}) + } + buf := bytes.NewBuffer(nil) + if err = tmpl.Execute(buf, data); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// DescriptorForData generates a Descriptor for API docs page. +func DescriptorForData(path string, data []byte, contentType string) definition.Descriptor { + return definition.Descriptor{ + Path: path, + Definitions: []definition.Definition{ + { + Method: definition.Get, + Consumes: []string{definition.MIMENone}, + Produces: []string{contentType}, + Function: func(context.Context) ([]byte, error) { + return data, nil + }, + Parameters: []definition.Parameter{}, + Results: definition.DataErrorResults(""), + }, + }, + } +} diff --git a/utils/generators/swagger/generator.go b/utils/generators/swagger/generator.go index bdfdbdee..0bc2603d 100644 --- a/utils/generators/swagger/generator.go +++ b/utils/generators/swagger/generator.go @@ -464,7 +464,8 @@ func (g *Generator) generateParameter(param *api.Parameter) []spec.Parameter { if len(param.Default) > 0 { parameter.Required = false } - if parameter.In != "body" { + body := "body" + if parameter.In != body { // Only body parameter can hold a schema. Other parameters uses type // and format. parameter.Type = schema.Type[0] @@ -479,6 +480,10 @@ func (g *Generator) generateParameter(param *api.Parameter) []spec.Parameter { parameter.Items.Format = schema.Items.Schema.Format } parameter.Schema = nil + } else { + // add parameter name for body, it required by swagger ui, + // cause api.Parameter.Name is always nil when In is body + parameter.Name = body } if len(param.Default) > 0 { diff --git a/utils/generators/utils/utils.go b/utils/generators/utils/utils.go new file mode 100644 index 00000000..d0f481a8 --- /dev/null +++ b/utils/generators/utils/utils.go @@ -0,0 +1,45 @@ +/* +Copyright 2020 Caicloud Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package utils + +import ( + "encoding/json" + + "github.com/caicloud/nirvana/utils/api" + "github.com/caicloud/nirvana/utils/generators/swagger" + "github.com/caicloud/nirvana/utils/project" +) + +// GenSwaggerData generates swagger data for definitions. +func GenSwaggerData(config *project.Config, definitions *api.Definitions) (map[string][]byte, error) { + generator := swagger.NewDefaultGenerator(config, definitions) + swaggers, err := generator.Generate() + if err != nil { + return nil, err + } + + files := make(map[string][]byte, len(swaggers)) + for filename, s := range swaggers { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return nil, err + } + + files[filename] = data + } + return files, nil +}