From f8e6310c7aef2325052b854059fe7880c77e0a02 Mon Sep 17 00:00:00 2001 From: caixw Date: Wed, 12 Jul 2023 22:53:05 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20restdoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/web/go.mod | 47 +++ cmd/web/go.sum | 105 ++++++ cmd/web/internal/restdoc/logger/logger.go | 77 ++++ .../internal/restdoc/logger/logger_test.go | 47 +++ .../restdoc/logger/loggertest/loggertest.go | 32 ++ .../logger/loggertest/loggertest_test.go | 24 ++ cmd/web/internal/restdoc/logger/term.go | 38 ++ cmd/web/internal/restdoc/logger/term_test.go | 38 ++ cmd/web/internal/restdoc/parser/api.go | 142 +++++++ cmd/web/internal/restdoc/parser/body.go | 153 ++++++++ cmd/web/internal/restdoc/parser/parser.go | 185 +++++++++ .../internal/restdoc/parser/parser_test.go | 35 ++ cmd/web/internal/restdoc/parser/restdoc.go | 167 +++++++++ .../internal/restdoc/parser/restdoc_test.go | 115 ++++++ .../internal/restdoc/parser/testdata/api.go | 36 ++ .../restdoc/parser/testdata/testdata.go | 33 ++ cmd/web/internal/restdoc/pkg/dir.go | 84 +++++ cmd/web/internal/restdoc/pkg/dir_test.go | 26 ++ cmd/web/internal/restdoc/pkg/pkg.go | 125 +++++++ cmd/web/internal/restdoc/pkg/pkg_test.go | 65 ++++ .../internal/restdoc/pkg/testdir/testdir.go | 6 + .../restdoc/pkg/testdir/testdir2/testdir2.go | 5 + cmd/web/internal/restdoc/restdoc.go | 55 +++ cmd/web/internal/restdoc/schema/error.go | 22 ++ cmd/web/internal/restdoc/schema/path.go | 38 ++ cmd/web/internal/restdoc/schema/path_test.go | 38 ++ cmd/web/internal/restdoc/schema/schema.go | 21 ++ cmd/web/internal/restdoc/schema/search.go | 350 ++++++++++++++++++ .../internal/restdoc/schema/search_test.go | 168 +++++++++ .../restdoc/schema/testdata/admin/admin.go | 16 + .../restdoc/schema/testdata/schema.go | 22 ++ cmd/web/internal/restdoc/utils/utils.go | 95 +++++ cmd/web/internal/restdoc/utils/utils_test.go | 98 +++++ cmd/web/main.go | 56 +++ 34 files changed, 2564 insertions(+) create mode 100644 cmd/web/go.mod create mode 100644 cmd/web/go.sum create mode 100644 cmd/web/internal/restdoc/logger/logger.go create mode 100644 cmd/web/internal/restdoc/logger/logger_test.go create mode 100644 cmd/web/internal/restdoc/logger/loggertest/loggertest.go create mode 100644 cmd/web/internal/restdoc/logger/loggertest/loggertest_test.go create mode 100644 cmd/web/internal/restdoc/logger/term.go create mode 100644 cmd/web/internal/restdoc/logger/term_test.go create mode 100644 cmd/web/internal/restdoc/parser/api.go create mode 100644 cmd/web/internal/restdoc/parser/body.go create mode 100644 cmd/web/internal/restdoc/parser/parser.go create mode 100644 cmd/web/internal/restdoc/parser/parser_test.go create mode 100644 cmd/web/internal/restdoc/parser/restdoc.go create mode 100644 cmd/web/internal/restdoc/parser/restdoc_test.go create mode 100644 cmd/web/internal/restdoc/parser/testdata/api.go create mode 100644 cmd/web/internal/restdoc/parser/testdata/testdata.go create mode 100644 cmd/web/internal/restdoc/pkg/dir.go create mode 100644 cmd/web/internal/restdoc/pkg/dir_test.go create mode 100644 cmd/web/internal/restdoc/pkg/pkg.go create mode 100644 cmd/web/internal/restdoc/pkg/pkg_test.go create mode 100644 cmd/web/internal/restdoc/pkg/testdir/testdir.go create mode 100644 cmd/web/internal/restdoc/pkg/testdir/testdir2/testdir2.go create mode 100644 cmd/web/internal/restdoc/restdoc.go create mode 100644 cmd/web/internal/restdoc/schema/error.go create mode 100644 cmd/web/internal/restdoc/schema/path.go create mode 100644 cmd/web/internal/restdoc/schema/path_test.go create mode 100644 cmd/web/internal/restdoc/schema/schema.go create mode 100644 cmd/web/internal/restdoc/schema/search.go create mode 100644 cmd/web/internal/restdoc/schema/search_test.go create mode 100644 cmd/web/internal/restdoc/schema/testdata/admin/admin.go create mode 100644 cmd/web/internal/restdoc/schema/testdata/schema.go create mode 100644 cmd/web/internal/restdoc/utils/utils.go create mode 100644 cmd/web/internal/restdoc/utils/utils_test.go create mode 100644 cmd/web/main.go diff --git a/cmd/web/go.mod b/cmd/web/go.mod new file mode 100644 index 00000000..88cc38ae --- /dev/null +++ b/cmd/web/go.mod @@ -0,0 +1,47 @@ +module github.com/issue9/web/cmd/web + +go 1.20 + +require ( + github.com/getkin/kin-openapi v0.118.0 + github.com/issue9/assert/v3 v3.0.4 + github.com/issue9/cmdopt v0.12.2 + github.com/issue9/localeutil v0.16.4 + github.com/issue9/query/v3 v3.1.2 + github.com/issue9/sliceutil v0.12.0 + github.com/issue9/term/v3 v3.1.2 + github.com/issue9/web v0.77.0 + golang.org/x/mod v0.12.0 + golang.org/x/text v0.10.0 +) + +replace github.com/issue9/web => ../../ + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/bradfitz/gomemcache v0.0.0-20230611145640-acc696258285 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/swag v0.19.5 // indirect + github.com/invopop/yaml v0.1.0 // indirect + github.com/issue9/config v0.3.0 // indirect + github.com/issue9/conv v1.3.4 // indirect + github.com/issue9/errwrap v0.3.1 // indirect + github.com/issue9/logs/v4 v4.5.1 // indirect + github.com/issue9/mux/v7 v7.2.0 // indirect + github.com/issue9/rands/v2 v2.0.0 // indirect + github.com/issue9/scheduled v0.13.0 // indirect + github.com/issue9/source v0.4.0 // indirect + github.com/issue9/unique/v2 v2.0.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.16.6 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/perimeterx/marshmallow v1.1.4 // indirect + github.com/redis/go-redis/v9 v9.0.5 // indirect + golang.org/x/sys v0.9.0 // indirect + golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/cmd/web/go.sum b/cmd/web/go.sum new file mode 100644 index 00000000..c00aaf7d --- /dev/null +++ b/cmd/web/go.sum @@ -0,0 +1,105 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/bradfitz/gomemcache v0.0.0-20230611145640-acc696258285 h1:Dr+ezPI5ivhMn/3WOoB86XzMhie146DNaBbhaQWZHMY= +github.com/bradfitz/gomemcache v0.0.0-20230611145640-acc696258285/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= +github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= +github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/getkin/kin-openapi v0.118.0 h1:z43njxPmJ7TaPpMSCQb7PN0dEYno4tyBPQcrFdHoLuM= +github.com/getkin/kin-openapi v0.118.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= +github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/issue9/assert/v3 v3.0.4 h1:WsYZQ6PQmM/pGFrbkn5GIXjWeVZHv+wcl2829UTX1Qc= +github.com/issue9/assert/v3 v3.0.4/go.mod h1:yft/uaskRpwQTyBT3n1zRl91SR1wNlO4fLZHzOa4bdM= +github.com/issue9/cmdopt v0.12.2 h1:gYFT5c/YdhA6yIo+GfGtc2PSd2licLoUWJwPehAUdJY= +github.com/issue9/cmdopt v0.12.2/go.mod h1:l//IcugcBwX+vCc2KrgC4ylU6rEzhjQyxno7hWoLCDE= +github.com/issue9/config v0.3.0 h1:KqCgANEoWAX6yaHd+iF/6NURV/FdDJZqsI7rb3giN2I= +github.com/issue9/config v0.3.0/go.mod h1:42vFLF+7PDV5s7z+X+Pm6Ska7l4gAMx9H5uhSPT2A+c= +github.com/issue9/conv v1.3.4 h1:v1j/p1lVNW4u1yrbUxxNCb61iTFnF86s+KAwS65MsBs= +github.com/issue9/conv v1.3.4/go.mod h1:TXM2DyyJhzZMSwp9cxwFW/OhP5JRVZPMg5XE8OMzwUY= +github.com/issue9/errwrap v0.3.1 h1:8g4lYJaGnoiXyZ1oZyH/7zPDGgw5RNiE9Q6ri9kE6Z8= +github.com/issue9/errwrap v0.3.1/go.mod h1:HLR0e5iimd2aJXM9YrThOsRj3/6lMtk77lVp7zyvJ4E= +github.com/issue9/localeutil v0.16.4 h1:6n0tzO/i2Dy6H1dDnAcojPtJqi3TpxsZI3xkV3h2VxE= +github.com/issue9/localeutil v0.16.4/go.mod h1:v1T+rFQMb/Sm5EReBFws4MarCJnRUrgO0ul7AuRDWwI= +github.com/issue9/logs/v4 v4.5.1 h1:947MRhWRHOjOJ6E51sKLKOuKiXxP1RuSmJljzbaA3zg= +github.com/issue9/logs/v4 v4.5.1/go.mod h1:kDO712WhvaoTRuQcB8+zvMt2DVK/HLOG18fZOa+ZkHM= +github.com/issue9/mux/v7 v7.2.0 h1:UXohwuZqTWlr85BmuYdiDZC48MbzQA+2NywPIWLPiBo= +github.com/issue9/mux/v7 v7.2.0/go.mod h1:x0w3B3DnTsrELEo0iIVhGEbjiDmwCAxSqr9c/o+dbfk= +github.com/issue9/query/v3 v3.1.2 h1:+ObxriMUTmv9qq8E4HWVByv9W2hnEXFw/FI69BlomO0= +github.com/issue9/query/v3 v3.1.2/go.mod h1:6SZyc4Ide0YuwRJk4w5aTzVJzQVU2smoWg+KlQiN+n4= +github.com/issue9/rands/v2 v2.0.0 h1:SdG8iflIzuWpa6CKLmMwn4pTZj+mSxS7s+1TCH6WXUE= +github.com/issue9/rands/v2 v2.0.0/go.mod h1:wTOXDlW9QUtba7o3I/RayfjH7mLYrER2TFHaIeKe96o= +github.com/issue9/scheduled v0.13.0 h1:2gsD7lvQp48ipdndJE/NXEL2KGA5Nf/hYiceKDgr9ag= +github.com/issue9/scheduled v0.13.0/go.mod h1:V9Za91dfNqqNrwBptdIEwJ6aKg8BfmixqTuMpXPa/A4= +github.com/issue9/sliceutil v0.12.0 h1:5SqNbNqiYrdYd4lfpYAVc5/3MBcB1JXMwDf1iAyViaI= +github.com/issue9/sliceutil v0.12.0/go.mod h1:KdnaZI342DHMrS6AkN0SSxqpFkJ6/4FyPpKm5m6S6Mk= +github.com/issue9/source v0.4.0 h1:8rM/BnNnoAYM6NB01zFC4YTZ0ykQH10rVG4KKcpLczs= +github.com/issue9/source v0.4.0/go.mod h1:6JDCwcuCYSBuWNLWCggjO9On+bWcMtNNYhTVSRE5FCI= +github.com/issue9/term/v3 v3.1.2 h1:lY5M3tsugr99ZHydRmAHQHGsm8E2levmEhUzv3fzLeE= +github.com/issue9/term/v3 v3.1.2/go.mod h1:VKfapnh//YDwjgU2z3EmCl1xwQ5taYWI5Io45u2ltnY= +github.com/issue9/unique/v2 v2.0.0 h1:pLfaHYWPoLrpH8T7LMikwNePlKplA7qtK8roUF4uCC0= +github.com/issue9/unique/v2 v2.0.0/go.mod h1:s0109PBDKIm7457yUaYSX/4WzHmeoGiZMhEuOB5tREI= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoUwLsk= +github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw= +github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= +github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= +golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= +golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cmd/web/internal/restdoc/logger/logger.go b/cmd/web/internal/restdoc/logger/logger.go new file mode 100644 index 00000000..5f3994f8 --- /dev/null +++ b/cmd/web/internal/restdoc/logger/logger.go @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT + +// Package logger 错误日志的处理 +package logger + +import ( + "go/scanner" + + "golang.org/x/mod/modfile" +) + +// 日志类型 +const ( + Unknown Type = iota + Info + Warning + Cancelled + ModSyntax + GoSyntax // Go 的语法错误 + DocSyntax // 文档语法错误 + typeSize +) + +type Type int8 + +type Entry struct { + Filename string + Line int + Msg any + Type Type +} + +type Logger struct { + handler func(*Entry) + count int +} + +// handler 用于解决如何输出日志对象 [Entry] +func New(handler func(*Entry)) *Logger { + return &Logger{handler: handler} +} + +// Count 接收到的日志数量 +func (l *Logger) Count() int { return l.count } + +func (l *Logger) Log(t Type, msg any, filename string, line int) { + l.count++ + l.handler(&Entry{ + Filename: filename, + Line: line, + Msg: msg, + Type: t, + }) +} + +func (l *Logger) LogWithoutPos(t Type, msg any) { l.Log(t, msg, "", 0) } + +// LogError 将 go 文件解析中的错误输出 +// +// filename 和 line 仅在 err 不携带文件信息时才会用到。 +func (l *Logger) LogError(t Type, err error, filename string, line int) { + if se, ok := err.(*scanner.Error); ok { + l.Log(t, se.Msg, se.Pos.Filename, se.Pos.Line) + } else if sel, ok := err.(scanner.ErrorList); ok { + for _, se = range sel { + l.Log(t, se.Msg, se.Pos.Filename, se.Pos.Line) + } + } else if me, ok := err.(*modfile.Error); ok { + l.Log(t, me.Err, filename, me.Pos.Line) + } else if mel, ok := err.(modfile.ErrorList); ok { + for _, e := range mel { + l.Log(t, e.Err, filename, e.Pos.Line) + } + } else { + l.Log(t, se, filename, line) + } +} diff --git a/cmd/web/internal/restdoc/logger/logger_test.go b/cmd/web/internal/restdoc/logger/logger_test.go new file mode 100644 index 00000000..bd88b4aa --- /dev/null +++ b/cmd/web/internal/restdoc/logger/logger_test.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT + +package logger + +import ( + "bytes" + "errors" + "fmt" + "go/scanner" + "go/token" + "testing" + + "github.com/issue9/assert/v3" + "golang.org/x/mod/modfile" +) + +func TestLogger(t *testing.T) { + a := assert.New(t, false) + + buf := new(bytes.Buffer) + l := New(func(e *Entry) { + fmt.Fprintln(buf, e) + }) + a.NotNil(l).Zero(l.Count()) + + e1 := &scanner.Error{Pos: token.Position{Filename: "f1.go"}, Msg: "e1"} + e2 := &scanner.Error{Pos: token.Position{Filename: "f1.go"}, Msg: "e2"} + l.LogError(Unknown, e1, "f1.go", 0) + a.Equal(1, l.Count()).True(buf.Len() > 0) + + list := scanner.ErrorList{e1, e2} + l.LogError(Unknown, list, "f1.go", 0) + a.Equal(3, l.Count()).True(buf.Len() > 0) + + me := &modfile.Error{ + Err: errors.New("err"), + Pos: modfile.Position{ + Line: 5, + LineRune: 10, + }, + } + l.LogError(ModSyntax, me, "f1.go", 0) + a.Equal(4, l.Count()).True(buf.Len() > 0) + + l.LogError(Unknown, me.Err, "f1.go", 0) + a.Equal(5, l.Count()).True(buf.Len() > 0) +} diff --git a/cmd/web/internal/restdoc/logger/loggertest/loggertest.go b/cmd/web/internal/restdoc/logger/loggertest/loggertest.go new file mode 100644 index 00000000..2df71931 --- /dev/null +++ b/cmd/web/internal/restdoc/logger/loggertest/loggertest.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT + +// Package loggertest 提供 logger 的测试用例 +package loggertest + +import ( + "os" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +type Tester struct { + *logger.Logger + Entries map[logger.Type][]*logger.Entry +} + +func New() *Tester { + t := &Tester{ + Entries: make(map[logger.Type][]*logger.Entry, 10), + } + + f := logger.BuildTermHandler(os.Stderr, message.NewPrinter(language.SimplifiedChinese)) + + t.Logger = logger.New(func(e *logger.Entry) { + t.Entries[e.Type] = append(t.Entries[e.Type], e) + f(e) + }) + + return t +} diff --git a/cmd/web/internal/restdoc/logger/loggertest/loggertest_test.go b/cmd/web/internal/restdoc/logger/loggertest/loggertest_test.go new file mode 100644 index 00000000..2b028a25 --- /dev/null +++ b/cmd/web/internal/restdoc/logger/loggertest/loggertest_test.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT + +package loggertest + +import ( + "errors" + "testing" + + "github.com/issue9/assert/v3" + "github.com/issue9/web/cmd/web/internal/restdoc/logger" +) + +func TestTester(t *testing.T) { + a := assert.New(t, false) + + lt := New() + a.NotNil(lt) + lt.Log(logger.Cancelled, "aaa", "", 0) + lt.LogError(logger.GoSyntax, errors.New("text string"), "", 0) + + a.Length(lt.Entries[logger.Cancelled], 1). + Length(lt.Entries[logger.GoSyntax], 1). + Length(lt.Entries[logger.DocSyntax], 0) +} diff --git a/cmd/web/internal/restdoc/logger/term.go b/cmd/web/internal/restdoc/logger/term.go new file mode 100644 index 00000000..4864bf67 --- /dev/null +++ b/cmd/web/internal/restdoc/logger/term.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +package logger + +import ( + "fmt" + "io" + + "github.com/issue9/localeutil" + "github.com/issue9/term/v3/colors" +) + +var typeColors = map[Type]colors.Color{ + Unknown: colors.Red, + Info: colors.Green, + Warning: colors.Yellow, + Cancelled: colors.Yellow, + ModSyntax: colors.Red, + GoSyntax: colors.Red, + DocSyntax: colors.Red, +} + +func BuildTermHandler(w io.Writer, p *localeutil.Printer) func(*Entry) { + return func(e *Entry) { + var msg string + if l, ok := e.Msg.(localeutil.LocaleStringer); ok { + msg = l.LocaleString(p) + } else { + msg = fmt.Sprint(e.Msg) + } + + if e.Filename != "" { + msg = localeutil.Phrase("%s at %s:%d\n", msg, e.Filename, e.Line).LocaleString(p) + } + + colors.Fprintf(w, colors.Normal, typeColors[e.Type], colors.Default, msg) + } +} diff --git a/cmd/web/internal/restdoc/logger/term_test.go b/cmd/web/internal/restdoc/logger/term_test.go new file mode 100644 index 00000000..10d47feb --- /dev/null +++ b/cmd/web/internal/restdoc/logger/term_test.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +package logger + +import ( + "bytes" + "testing" + + "github.com/issue9/assert/v3" + "github.com/issue9/localeutil" + "golang.org/x/text/language" + "golang.org/x/text/message" + "golang.org/x/text/message/catalog" +) + +func TestTypeColors(t *testing.T) { + a := assert.New(t, false) + a.Equal(typeSize, len(typeColors)) +} + +func TestBuildTermHandler(t *testing.T) { + a := assert.New(t, false) + + cata := catalog.NewBuilder() + cata.SetString(language.SimplifiedChinese, "%s at %s:%d\n", "%s 位于 %s:%d\n") + cata.SetString(language.SimplifiedChinese, "error", "ERROR") + p := message.NewPrinter(language.SimplifiedChinese, message.Catalog(cata)) + buf := new(bytes.Buffer) + l := New(BuildTermHandler(buf, p)) + a.NotNil(l) + + l.Log(Info, "error", "f.go", 10) + a.Contains(buf.String(), "error 位于 f.go:10\n") + + buf.Reset() + l.Log(Info, localeutil.Error("error"), "f.go", 10) + a.Contains(buf.String(), "ERROR 位于 f.go:10\n") +} diff --git a/cmd/web/internal/restdoc/parser/api.go b/cmd/web/internal/restdoc/parser/api.go new file mode 100644 index 00000000..abfb39df --- /dev/null +++ b/cmd/web/internal/restdoc/parser/api.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT + +package parser + +import ( + "strings" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "github.com/issue9/web/cmd/web/internal/restdoc/schema" + "github.com/issue9/web/cmd/web/internal/restdoc/utils" +) + +func (doc *Parser) parseAPI(t *openapi3.T, currPath, suffix string, lines []string, ln int, filename string) { + opt := openapi3.NewOperation() + opt.Responses = openapi3.NewResponses() + + words, l := utils.SplitSpaceN(suffix, 3) // GET /users *desc + var method, path string + if l < 2 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln) + return + } + method, path = words[0], words[1] + opt.Summary = words[2] + + var req request + resps := map[string]*response{} + + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + switch tag, suffix := utils.CutTag(line); strings.ToLower(tag) { + case "@id": // @id get_users + opt.OperationID = suffix + case "@tag": // @tag t1 t2 + opt.Tags = strings.Fields(suffix) + case "@header": // @header key *desc + doc.addCookieHeader(opt, openapi3.ParameterInHeader, suffix, filename, ln+i) + case "@cookie": // @cookie name *desc + doc.addCookieHeader(opt, openapi3.ParameterInCookie, suffix, filename, ln+i) + case "@path": // @path name type *desc + doc.addPath(opt, suffix, filename, ln+i) + case "@query": // @query object.path *desc + doc.addQuery(t, opt, currPath, suffix, filename, ln+i) + case "@req": // @req object.path *desc + if !doc.parseRequest(&req, t, suffix, filename, currPath, ln+i) { + return + } + case "@req-types": // @req-types application/json application/xml + req.media = utils.SplitSpace(suffix) + case "@resp": // @resp 200 object.path *desc + if !doc.parseResponse(resps, t, suffix, filename, currPath, ln+i) { + return + } + case "@resp-ref": // @resp-ref 200 name + words, l := utils.SplitSpaceN(suffix, 2) + if l != 2 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln+i) + return + } + opt.Responses[words[0]] = &openapi3.ResponseRef{Ref: words[1]} + case "@resp-types": // @resp-types status application/json application/xml + if !doc.parseResponseType(resps, t, suffix, filename, currPath, ln+i) { + return + } + + case "resp-header": // @resp-header 200 h1 *desc + if !doc.parseResponseHeader(resps, t, suffix, filename, currPath, ln+i) { + return + } + case "##": // 可能是 ## callback + // TODO + default: + opt.Description = strings.Join(lines[i:], "") // TODO 改为 markdown + } + } + + doc.addRequestBody(opt, &req) + doc.addResponses(opt, resps) + t.AddOperation(path, method, opt) +} + +func (doc *Parser) addQuery(t *openapi3.T, opt *openapi3.Operation, currPath, suffix, filename string, ln int) { + words, l := utils.SplitSpaceN(suffix, 2) + if l < 1 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln) + return + } + + s, err := doc.search.New(t, currPath, words[0], true) + if err != nil { + if serr, ok := err.(*schema.Error); ok { + doc.l.Log(serr.Type, serr.Msg, doc.file(serr.Pos), doc.line(serr.Pos)) + return + } + doc.l.Log(logger.DocSyntax, err, filename, ln) + return + } + + opt.AddParameter(&openapi3.Parameter{In: openapi3.ParameterInQuery, Schema: s, Description: words[1]}) +} + +func (doc *Parser) addPath(opt *openapi3.Operation, suffix, filename string, ln int) { + words, l := utils.SplitSpaceN(suffix, 3) + if l < 2 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln) + return + } + + s, err := schema.NewPath(words[1]) + if err != nil { + doc.l.Log(logger.DocSyntax, err, filename, ln) + return + } + + opt.AddParameter(&openapi3.Parameter{ + Schema: s, + In: openapi3.ParameterInPath, + Description: words[2], + Name: words[0], + }) +} + +// 处理 @header 或是 @cookie +// +// 语法如下: @header h1 *desc 或是 @cookie c1 *desc +// 两者结构完全相同,处理方式也相同。 +func (doc *Parser) addCookieHeader(opt *openapi3.Operation, in, suffix, filename string, ln int) { + words, l := utils.SplitSpaceN(suffix, 2) + if l < 1 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln) + return + } + + schema := openapi3.NewSchemaRef("", openapi3.NewStringSchema()) + opt.AddParameter(&openapi3.Parameter{In: in, Schema: schema, Name: words[0], Description: words[1]}) +} diff --git a/cmd/web/internal/restdoc/parser/body.go b/cmd/web/internal/restdoc/parser/body.go new file mode 100644 index 00000000..40f2185c --- /dev/null +++ b/cmd/web/internal/restdoc/parser/body.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MIT + +package parser + +import ( + "github.com/getkin/kin-openapi/openapi3" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "github.com/issue9/web/cmd/web/internal/restdoc/schema" + "github.com/issue9/web/cmd/web/internal/restdoc/utils" +) + +type ( + request struct { + schema *openapi3.SchemaRef + desc string + media []string + } + + response struct { + schema *openapi3.SchemaRef + desc string + media []string + header map[string]string + } +) + +// 解析 @req 的内容并将其写入 req +func (doc *Parser) parseRequest(req *request, t *openapi3.T, suffix, filename, currPath string, ln int) (ok bool) { + words, l := utils.SplitSpaceN(suffix, 2) + if l < 1 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln) + return false + } + + s, err := doc.search.New(t, currPath, words[0], false) + if err != nil { + if serr, ok := err.(*schema.Error); ok { + doc.l.Log(serr.Type, serr.Msg, doc.file(serr.Pos), doc.line(serr.Pos)) + return false + } + doc.l.Log(logger.DocSyntax, err, filename, ln) + return false + } + + req.schema = s + req.desc = words[1] + + return true +} + +func (doc *Parser) addRequestBody(o *openapi3.Operation, r *request) { + req := openapi3.NewRequestBody() + req.Content = doc.newContents(r.schema, r.media...) + req.Description = r.desc + o.RequestBody = &openapi3.RequestBodyRef{Value: req} +} + +// 解析 @resp 内容至 resps +func (doc *Parser) parseResponse(resps map[string]*response, t *openapi3.T, suffix, filename, currPath string, ln int) (ok bool) { + words, l := utils.SplitSpaceN(suffix, 3) + if l < 2 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln) + return false + } + + s, err := doc.search.New(t, currPath, words[1], false) + if err != nil { + if serr, ok := err.(*schema.Error); ok { + doc.l.Log(serr.Type, serr.Msg, doc.file(serr.Pos), doc.line(serr.Pos)) + return false + } + doc.l.Log(logger.DocSyntax, err, filename, ln) + return false + } + + if resp, found := resps[words[0]]; found { + resp.desc = words[2] + resp.schema = s + } + resps[words[0]] = &response{desc: words[2], schema: s} + + return true +} + +// @resp-header 200 header desc +func (doc *Parser) parseResponseHeader(resps map[string]*response, t *openapi3.T, suffix, filename, currPath string, ln int) bool { + words, l := utils.SplitSpaceN(suffix, 3) + if l != 3 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln) + return false + } + + if resp, found := resps[words[0]]; found { + resp.header[words[1]] = words[2] + } + resps[words[0]] = &response{header: map[string]string{words[1]: words[2]}} + + return true +} + +// @resp-type 200 application/json application/xml +func (doc *Parser) parseResponseType(resps map[string]*response, t *openapi3.T, suffix, filename, currPath string, ln int) bool { + words, l := utils.SplitSpaceN(suffix, 2) + if l != 2 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln) + return false + } + + types := utils.SplitSpace(words[1]) + if resp, found := resps[words[0]]; found { + resp.media = append(resp.media, types...) + } + resps[words[0]] = &response{media: types} + + return true +} + +func (doc *Parser) addResponses(o *openapi3.Operation, resps map[string]*response) { + for status, r := range resps { + resp := openapi3.NewResponse() + resp.Description = &r.desc + resp.Content = doc.newContents(r.schema, r.media...) + resp.Headers = make(openapi3.Headers, len(r.header)) + for h, desc := range r.header { + schema := openapi3.NewSchemaRef("", openapi3.NewStringSchema()) + p := openapi3.Parameter{In: openapi3.ParameterInHeader, Schema: schema, Description: desc} + resp.Headers[h] = &openapi3.HeaderRef{Value: &openapi3.Header{Parameter: p}} + } + + if o.Responses == nil { + o.Responses = openapi3.NewResponses() + } + o.Responses[status] = &openapi3.ResponseRef{Value: resp} + } +} + +// 当 media 为空时则直接采用 doc.media +func (doc *Parser) newContents(s *openapi3.SchemaRef, media ...string) openapi3.Content { + c := openapi3.NewContent() + + if len(media) == 0 { + media = doc.media + } + + mt := openapi3.NewMediaType() + mt.Schema = s + for _, m := range media { + c[m] = mt + } + + return c +} diff --git a/cmd/web/internal/restdoc/parser/parser.go b/cmd/web/internal/restdoc/parser/parser.go new file mode 100644 index 00000000..884f9ac7 --- /dev/null +++ b/cmd/web/internal/restdoc/parser/parser.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT + +// Package parser 文档内容分析 +package parser + +import ( + "context" + "go/ast" + "go/token" + "strings" + "sync" + "unicode" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/issue9/localeutil" + "github.com/issue9/sliceutil" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "github.com/issue9/web/cmd/web/internal/restdoc/pkg" + "github.com/issue9/web/cmd/web/internal/restdoc/schema" + "github.com/issue9/web/cmd/web/internal/restdoc/utils" +) + +var errSyntax = localeutil.Error("syntax error") + +// Parser 文档分析对象 +type Parser struct { + pkgsM sync.Mutex + pkgs []*pkg.Package + search schema.SearchFunc + fset *token.FileSet + + media []string + + // api 的部分功能是依赖 restdoc 的, + // 在 restdoc 未解析的情况下,所有的 api 注释都要缓存。 + apiComments []*comments + + parsed bool + l *logger.Logger +} + +type comments struct { + lines []string + pos token.Pos + modPath string +} + +// New 声明 RESTDoc 对象 +func New(l *logger.Logger) *Parser { + doc := &Parser{ + pkgs: make([]*pkg.Package, 0, 10), + fset: token.NewFileSet(), + + apiComments: make([]*comments, 0, 100), + + l: l, + } + + doc.search = func(s string) *pkg.Package { + if p, found := sliceutil.At(doc.pkgs, func(pkg *pkg.Package) bool { return pkg.Path == s }); found { + return p + } + return nil + } + + return doc +} + +// AddDir 添加 root 下的内容 +// +// 仅在调用 [RESTDoc.Openapi3] 之前添加有效果。 +// root 添加的目录; +func (doc *Parser) AddDir(ctx context.Context, root string, recursive bool) { + if doc.parsed { + panic("已经解析完成,无法再次添加!") + } + pkg.ScanDir(ctx, doc.fset, root, recursive, doc.append, doc.l) +} + +// line 返回 p 的行号 +func (doc *Parser) line(p token.Pos) int { return doc.fset.Position(p).Line } + +func (doc *Parser) file(p token.Pos) string { return doc.fset.File(p).Name() } + +func (doc *Parser) append(p *pkg.Package) { + doc.pkgsM.Lock() + defer doc.pkgsM.Unlock() + + if sliceutil.Exists(doc.pkgs, func(pkg *pkg.Package) bool { return pkg.Path == p.Path }) { + doc.l.Log(logger.Unknown, localeutil.Phrase("package %s with the same name.", p.Path), "", 0) + return + } + + doc.pkgs = append(doc.pkgs, p) +} + +// OpenAPI 转换成 openapi3.T 对象 +func (doc *Parser) OpenAPI(ctx context.Context) *openapi3.T { + doc.parsed = true // 阻止 doc.AddDir + + t := schema.NewOpenAPI() + + wg := &sync.WaitGroup{} + for _, p := range doc.pkgs { + select { + case <-ctx.Done(): + doc.l.LogWithoutPos(logger.Cancelled, context.Canceled) + return nil + default: + wg.Add(1) + go func(p *pkg.Package) { + defer wg.Done() + doc.parsePackage(ctx, t, p) + }(p) + } + } + wg.Wait() + + for _, c := range doc.apiComments { + for index, line := range c.lines { + if len(line) <= 2 { + continue + } + if tag, suffix := utils.CutTag(line[2:]); suffix != "" && strings.ToLower(tag) == "api" { + doc.parseAPI(t, c.modPath, suffix, c.lines[index+1:], doc.line(c.pos)+index, doc.file(c.pos)) + } + } + } + + return t +} + +func (doc *Parser) parsePackage(ctx context.Context, t *openapi3.T, pkg *pkg.Package) { + wg := &sync.WaitGroup{} + for _, f := range pkg.Files { + select { + case <-ctx.Done(): + doc.l.LogWithoutPos(logger.Cancelled, context.Canceled) + return + default: + wg.Add(1) + go func(f *ast.File) { + defer wg.Done() + doc.parseFile(t, pkg.Path, f) + }(f) + } + } + wg.Wait() +} + +func (doc *Parser) parseFile(t *openapi3.T, importPath string, f *ast.File) { +LOOP: + for _, c := range f.Comments { + lines := strings.Split(c.Text(), "\n") + if len(lines) <= 2 { // 少于 2 行的肯定不是 + continue + } + + for index, line := range lines { + if len(line) < 6 || // 最起码得包含 # api 5 个字符 + line[0] != '#' || !unicode.IsSpace(rune(line[1])) { // # 之后至少一个空格 + continue + } + + if tag, suffix := utils.CutTag(line[2:]); suffix != "" { + switch strings.ToLower(tag) { + case "api": + if t.Info != nil { + doc.parseAPI(t, importPath, suffix, lines[index+1:], doc.line(c.Pos()), doc.file(c.Pos())) + } else { + doc.apiComments = append(doc.apiComments, &comments{ + lines: lines, // 保存所有行,而不是从当前页开始,方便后续判断正确的行号。 + pos: c.Pos(), + modPath: importPath, + }) + } + case "restdoc": + doc.parseRESTDoc(t, importPath, suffix, lines[index+1:], doc.line(c.Pos())+index, doc.file(c.Pos())) + } + continue LOOP + } + } + } +} diff --git a/cmd/web/internal/restdoc/parser/parser_test.go b/cmd/web/internal/restdoc/parser/parser_test.go new file mode 100644 index 00000000..0a34a2c5 --- /dev/null +++ b/cmd/web/internal/restdoc/parser/parser_test.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT + +package parser + +import ( + "context" + "testing" + + "github.com/issue9/assert/v3" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "github.com/issue9/web/cmd/web/internal/restdoc/logger/loggertest" +) + +func TestParser(t *testing.T) { + a := assert.New(t, false) + l := loggertest.New() + p := New(l.Logger) + + p.AddDir(context.Background(), "./testdata", true) + d := p.OpenAPI(context.Background()) + a.NotNil(d). + Length(l.Entries[logger.GoSyntax], 0). + Length(l.Entries[logger.Cancelled], 0). + Length(l.Entries[logger.DocSyntax], 0). + Length(l.Entries[logger.Unknown], 0) + + a.NotNil(d.Info).Equal(d.Info.Version, "1.0") + + login := d.Paths["/login"].Post + a.NotNil(login). + Length(login.Parameters, 3). + NotNil(login.RequestBody). + Length(login.Responses, 5) // 包含默认的 default +} diff --git a/cmd/web/internal/restdoc/parser/restdoc.go b/cmd/web/internal/restdoc/parser/restdoc.go new file mode 100644 index 00000000..da3ed7b3 --- /dev/null +++ b/cmd/web/internal/restdoc/parser/restdoc.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT + +package parser + +import ( + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/issue9/localeutil" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "github.com/issue9/web/cmd/web/internal/restdoc/utils" +) + +// 解析 # restdoc 之后的内容 +// +// title 表示 # restdoc 至该行的行尾内容; +// lines 表示第二行开始的所有内容,每一行不应该包含结尾的换行符; +// ln 表示 title 所在行的行号,在出错时,用于记录日志; +// filename 表示所在的文件,在出错时,用于记录日志; +func (doc *Parser) parseRESTDoc(t *openapi3.T, currPath, title string, lines []string, ln int, filename string) { + ln++ // for lines 索引从 0 开始,所有行号需要加上 1 。 + + info := &openapi3.Info{ + Title: title, + } + + if t.Info != nil { + doc.l.Log(logger.DocSyntax, localeutil.Phrase("已经存在一个 # restdoc 节点"), filename, ln) + return + } + + resps := make(map[string]*response, 10) + +LOOP: + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + switch tag, suffix := utils.CutTag(line); strings.ToLower(tag) { + case "@tag": // @tag name *desc + words, l := utils.SplitSpaceN(suffix, 2) + if l < 1 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln+i) + continue LOOP + } + t.Tags = append(t.Tags, &openapi3.Tag{Name: words[0], Description: words[1]}) + case "@server": // @server https://example.com *desc + words, l := utils.SplitSpaceN(suffix, 2) + if l < 1 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln+i) + continue LOOP + } + t.Servers = append(t.Servers, &openapi3.Server{URL: words[0], Description: words[1]}) + case "@license": // @license MIT *https://example.com/license + words, l := utils.SplitSpaceN(suffix, 2) + if l < 1 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln+i) + continue LOOP + } + info.License = &openapi3.License{Name: words[0], URL: words[1]} + case "@term": // @term https://example.com/term.html + info.TermsOfService = suffix + case "@version": // @version 1.0.0 + info.Version = suffix + case "@contact": // @contact name *https://example.com/contact *contact@example.com + words, l := utils.SplitSpaceN(suffix, 3) + if l == 0 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln+i) + continue LOOP + } + info.Contact = buildContact(words) + case "@media": // @media application/json application/xml + doc.media = utils.SplitSpace(suffix) + case "@resp": // @resp name object.path desc + if !doc.parseResponse(resps, t, suffix, filename, currPath, ln+i) { + continue LOOP + } + case "@resp-types": // @resp-types name application/json application/xml + if !doc.parseResponseType(resps, t, suffix, filename, currPath, ln+i) { + continue LOOP + } + case "resp-header": // @resp-header name h1 *desc + if !doc.parseResponseHeader(resps, t, suffix, filename, currPath, ln+i) { + continue LOOP + } + case "@scy-http": // @scy-http name scheme format *desc + words, l := utils.SplitSpaceN(suffix, 4) + if l < 3 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln+i) + continue LOOP + } + + ss := openapi3.NewSecurityScheme() + ss.Type = "http" + ss.Scheme = words[1] + ss.WithBearerFormat(words[2]) + ss.Description = words[3] + println(t.Components.SecuritySchemes == nil) + t.Components.SecuritySchemes[words[0]] = &openapi3.SecuritySchemeRef{Value: ss} + case "@scy-apikey": // @scy-apikey name param-name in *desc + words, l := utils.SplitSpaceN(suffix, 4) + if l < 3 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln+i) + continue LOOP + } + + ss := openapi3.NewSecurityScheme() + ss.Type = "apiKey" + ss.Name = words[1] + ss.In = words[2] + ss.Description = words[3] + t.Components.SecuritySchemes[words[0]] = &openapi3.SecuritySchemeRef{Value: ss} + case "@scy-openid": // @scy-openid name url *desc + words, l := utils.SplitSpaceN(suffix, 3) + if l < 2 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln+i) + continue LOOP + } + + ss := openapi3.NewSecurityScheme() + ss.Type = "openIdConnect" + ss.OpenIdConnectUrl = words[1] + ss.Description = words[2] + t.Components.SecuritySchemes[words[0]] = &openapi3.SecuritySchemeRef{Value: ss} + // TODO 支持 security-oauth2 的相关功能 + case "@doc": // @doc url desc + words, l := utils.SplitSpaceN(suffix, 2) + if l < 1 { + doc.l.Log(logger.DocSyntax, errSyntax, filename, ln+i) + continue LOOP + } + + t.ExternalDocs = &openapi3.ExternalDocs{URL: words[0], Description: words[1]} + default: // 不认识的标签,表示元数据部分结束,将剩余部分直接作为 info.Description + info.Description = strings.Join(lines[i:], "\n") + break LOOP + } + } + + for status, r := range resps { + resp := openapi3.NewResponse() + resp.Description = &r.desc + resp.Content = doc.newContents(r.schema, r.media...) + t.Components.Responses[status] = &openapi3.ResponseRef{Value: resp} + } + + t.Info = info +} + +func buildContact(words []string) *openapi3.Contact { + c := &openapi3.Contact{} + for _, word := range words { + switch { + case utils.IsURL(word): + c.URL = word + case utils.IsEmail(word): + c.Email = word + default: + c.Name = word + } + } + + return c +} diff --git a/cmd/web/internal/restdoc/parser/restdoc_test.go b/cmd/web/internal/restdoc/parser/restdoc_test.go new file mode 100644 index 00000000..bf1e12d1 --- /dev/null +++ b/cmd/web/internal/restdoc/parser/restdoc_test.go @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT + +package parser + +import ( + "testing" + + "github.com/issue9/assert/v3" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "github.com/issue9/web/cmd/web/internal/restdoc/logger/loggertest" + "github.com/issue9/web/cmd/web/internal/restdoc/schema" +) + +func TestRESTDoc_parseRESTDoc(t *testing.T) { + a := assert.New(t, false) + + l := loggertest.New() + p := New(l.Logger) + d := schema.NewOpenAPI() + lines := []string{ + "@version 1.0.0", + "@tag user user tag desc ", + "@server https://example.com/v1 v1 desc", + "@tag admin admin tag desc", + "@server https://example.com/v2 v2 desc", + "@license mit https://example.com/license", + "@contact name https://example.com x@example.com", + "@term https://example.com/term", + "@media application/json application/xml", + "@doc https://doc.example.com", + "@scy-http http-security bearer format http bearer auth", + "@scy-apikey apikey-security key header apikey header auth", + "@scy-openid openid-security https://example.com/openid openid auth", + "", + "# markdown desc", + "line 2", + } + p.parseRESTDoc(d, "restdoc example", "github.com/issue9/web", lines, 5, "example.go") + + a.Equal(0, l.Count()). + Length(d.Tags, 2).Equal(d.Tags[0].Description, "user tag desc"). + Length(d.Servers, 2). + Equal(d.Info.License.Name, "mit"). + Equal(d.Info.TermsOfService, "https://example.com/term"). + Equal(d.Info.Contact.Name, "name"). + Equal(d.Info.Description, "# markdown desc\nline 2"). + Equal(p.media, []string{"application/json", "application/xml"}). + Equal(d.ExternalDocs.URL, "https://doc.example.com") + + http := d.Components.SecuritySchemes["http-security"] + a.NotNil(http). + Equal(http.Value.Scheme, "bearer"). + Equal(http.Value.BearerFormat, "format"). + Equal(http.Value.Description, "http bearer auth") + + apikey := d.Components.SecuritySchemes["apikey-security"] + a.NotNil(apikey). + Equal(apikey.Value.Name, "key"). + Equal(apikey.Value.In, "header"). + Equal(apikey.Value.Description, "apikey header auth") + + openid := d.Components.SecuritySchemes["openid-security"] + a.NotNil(openid). + Equal(openid.Value.OpenIdConnectUrl, "https://example.com/openid"). + Equal(openid.Value.Description, "openid auth") + + // 测试行号是否正确 + l = loggertest.New() + p = New(l.Logger) + d = schema.NewOpenAPI() + lines = []string{ + "@version 1.0.0", + "@tag user user tag desc", + "@server", + "", + "# markdown desc", + "line 2", + } + p.parseRESTDoc(d, "restdoc example", "github.com/issue9/web", lines, 5, "example.go") + + a.Equal(1, l.Count()). + Length(d.Tags, 1). + Equal(d.Info.Description, "# markdown desc\nline 2"). + Equal(l.Entries[logger.DocSyntax][0].Line, 8) +} + +func TestBuildContact(t *testing.T) { + a := assert.New(t, false) + + c := buildContact([]string{"name"}) + a.Equal(c.Name, "name"). + Empty(c.Email). + Empty(c.URL) + + c = buildContact([]string{"https://example.com"}) + a.Equal(c.URL, "https://example.com"). + Empty(c.Email). + Empty(c.Name) + + c = buildContact([]string{"x@example.com"}) + a.Equal(c.Email, "x@example.com"). + Empty(c.URL). + Empty(c.Name) + + c = buildContact([]string{"x@example.com", "name"}) + a.Equal(c.Email, "x@example.com"). + Empty(c.URL). + Equal(c.Name, "name") + + c = buildContact([]string{"x@example.com", "name", "https://example.com"}) + a.Equal(c.Email, "x@example.com"). + Equal(c.URL, "https://example.com"). + Equal(c.Name, "name") +} diff --git a/cmd/web/internal/restdoc/parser/testdata/api.go b/cmd/web/internal/restdoc/parser/testdata/api.go new file mode 100644 index 00000000..aaaec5dc --- /dev/null +++ b/cmd/web/internal/restdoc/parser/testdata/api.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT + +package testdata + +// api 函数说明 +// +// # api POST /login 登录 +// @tag users +// @req req 登录的账号信息 +// @header h1 +// @cookie c1 desc +// @query query +// @resp 201 resp +// @resp-header 201 h1 h1 desc +// @resp-header 201 h2 h2 desc +// @resp-ref 400 400-resp +// @resp-ref 404 404-resp +// @resp 200 resp resp desc +// +// 如果有其它需要详细说的,在文档最后写入, +// 会被以 md 的格式传递给 api.Description +func login() {} + +type req struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type resp struct { + UID int `json:"uid"` + Token string `json:"token"` +} + +type query struct { + Type string `json:"type"` +} diff --git a/cmd/web/internal/restdoc/parser/testdata/testdata.go b/cmd/web/internal/restdoc/parser/testdata/testdata.go new file mode 100644 index 00000000..068258ff --- /dev/null +++ b/cmd/web/internal/restdoc/parser/testdata/testdata.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT + +// Package testdata 测试数据 +// +// 这是测试数据的说明 +// +// # restdoc RESTDoc 标题 +// +// @tag admin admin API +// @tag users users API +// @server https://api.example.com/v1 v1 api +// @server https://api.example.com/v2 v2 api +// @license mit https://license.example.com/mit +// @term https://term.example.com +// @version 1.0 +// @media application/json application/xml +// @resp 400-resp resp400 400 错误 +// @resp 404-resp resp404 not found +// @resp-types 400-resp application/problem+json application/problem+xml +// @resp-types 404-resp application/problem+json application/problem+xml +// +// # 其它文档说明 +// +// 这也将被传递维给 info.Description +package testdata + +type resp400 struct { + Status int `json:"status" xml:"status,attr"` +} + +type resp404 struct { + Status int `json:"status" xml:"status,attr"` +} diff --git a/cmd/web/internal/restdoc/pkg/dir.go b/cmd/web/internal/restdoc/pkg/dir.go new file mode 100644 index 00000000..792ae397 --- /dev/null +++ b/cmd/web/internal/restdoc/pkg/dir.go @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT + +package pkg + +import ( + "errors" + "io/fs" + "os" + "path" + "path/filepath" + + "github.com/issue9/sliceutil" + "golang.org/x/mod/modfile" +) + +// 获取 root 以及其子目录列表 +func getDirs(root string, recursive bool) ([]string, error) { + if !recursive { + return []string{root}, nil + } + + dirs := make([]string, 0, 100) + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err == nil && d.IsDir() { + dirs = append(dirs, p) + } + return err + }) + + if err != nil { + return nil, err + } + return dirs, nil +} + +func getModPath(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", err + } + + pkgNames := make([]string, 0, 10) +LOOP: + for { + p := filepath.Join(abs, "go.mod") + stat, err := os.Stat(p) + switch { + case err == nil: + if stat.IsDir() { // 名为 go.mod 的目录 + pkgNames = append(pkgNames, stat.Name()) + abs = filepath.Dir(abs) + continue LOOP + } + + data, err := os.ReadFile(p) + if err != nil { + return "", err + } + mod, err := modfile.Parse(p, data, nil) + if err != nil { + return "", err + } + + pkgNames = append(pkgNames, mod.Module.Mod.Path) + sliceutil.Reverse(pkgNames) + return path.Join(pkgNames...), nil + case errors.Is(err, os.ErrNotExist): + // 这两行不能用 filepath.Split 代替,split 会为 abs1 留下最后的分隔符, + // 导致下一次的 filepath.Split 返回空的 file 值。 + base := filepath.Base(abs) + abs1 := filepath.Dir(abs) + + if abs1 == abs { // 到根目录了 + return "", os.ErrNotExist + } + + abs = abs1 + pkgNames = append(pkgNames, base) + continue LOOP + default: // 文件存在,但是出错。 + return "", err + } + } +} diff --git a/cmd/web/internal/restdoc/pkg/dir_test.go b/cmd/web/internal/restdoc/pkg/dir_test.go new file mode 100644 index 00000000..d2154134 --- /dev/null +++ b/cmd/web/internal/restdoc/pkg/dir_test.go @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +package pkg + +import ( + "testing" + + "github.com/issue9/assert/v3" +) + +func TestGetDirs(t *testing.T) { + a := assert.New(t, false) + + dirs, err := getDirs("./testdir", false) + a.NotError(err).Length(dirs, 1) + + dirs, err = getDirs("./testdir", true) + a.NotError(err).Length(dirs, 2) +} + +func TestGetModPath(t *testing.T) { + a := assert.New(t, false) + + p, err := getModPath("./") + a.NotError(err).Equal(p, "github.com/issue9/web/cmd/web/internal/restdoc/pkg") +} diff --git a/cmd/web/internal/restdoc/pkg/pkg.go b/cmd/web/internal/restdoc/pkg/pkg.go new file mode 100644 index 00000000..0e867960 --- /dev/null +++ b/cmd/web/internal/restdoc/pkg/pkg.go @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: MIT + +// Package pkg 用于对包的解析管理 +package pkg + +import ( + "context" + "go/ast" + "go/parser" + "go/token" + "os" + "path" + "path/filepath" + "strings" + "sync" + + "github.com/issue9/localeutil" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" +) + +type Package struct { + Path string // 当前包的 path + Files []*ast.File +} + +type AppendFunc = func(*Package) + +// AddDir 添加 root 下的内容 +// +// 仅在调用 [RESTDoc.Openapi3] 之前添加有效果。 +// root 添加的目录; +func ScanDir(ctx context.Context, fset *token.FileSet, root string, recursive bool, af AppendFunc, l *logger.Logger) { + root = filepath.Clean(root) + + l.LogWithoutPos(logger.Info, localeutil.Phrase("start parse %s ...\n", root)) + + dirs, err := getDirs(root, recursive) + if err != nil { + l.Log(logger.Unknown, err, root, 0) + return + } + + modPath, err := getModPath(root) + if err != nil { + l.Log(logger.Unknown, err, root, 0) + return + } + + wg := &sync.WaitGroup{} + for _, dir := range dirs { + select { + case <-ctx.Done(): + l.Log(logger.Cancelled, context.Canceled, dir, 0) + return + default: + wg.Add(1) + go func(dir string) { + defer wg.Done() + + suffix := strings.TrimPrefix(filepath.Clean(dir), root) + suffix = strings.TrimFunc(suffix, func(r rune) bool { return r == filepath.Separator }) + p := scan(ctx, fset, l, dir, path.Join(modPath, suffix)) + if p != nil { + af(p) + } + }(dir) + } + } + wg.Wait() + l.LogWithoutPos(logger.Info, localeutil.Phrase("parse %s complete\n", root)) +} + +// 扫描 dir 下的 go 文件 +// +// 不包含子目录和测试文件; +// modPath 为 dir 下 go 文件的导出路径; +func scan(ctx context.Context, fset *token.FileSet, l *logger.Logger, dir, modPath string) *Package { + entry, err := os.ReadDir(dir) + if err != nil { + l.Log(logger.Unknown, err, dir, 0) + return nil + } + + astFiles := make([]*ast.File, 0, len(entry)) + astFilesM := &sync.Mutex{} + appendFiles := func(f *ast.File) { + astFilesM.Lock() + defer astFilesM.Unlock() + astFiles = append(astFiles, f) + } + + wg := &sync.WaitGroup{} + for _, e := range entry { + select { + case <-ctx.Done(): + l.Log(logger.Cancelled, context.Canceled, dir, 0) + return nil + default: + // 路径、非 .go 扩展名 或是 _test.go 结尾的文件都忽略 + name := strings.ToLower(e.Name()) + if e.IsDir() || filepath.Ext(name) != ".go" || strings.HasSuffix(name, "_test.go") { + continue + } + + wg.Add(1) + go func(path string) { + defer wg.Done() + + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err == nil { + appendFiles(f) + return + } + l.LogError(logger.GoSyntax, err, path, 0) + }(filepath.Join(dir, e.Name())) + } + } + wg.Wait() + + return &Package{ + Path: modPath, + Files: astFiles, + } +} diff --git a/cmd/web/internal/restdoc/pkg/pkg_test.go b/cmd/web/internal/restdoc/pkg/pkg_test.go new file mode 100644 index 00000000..51ad6c3e --- /dev/null +++ b/cmd/web/internal/restdoc/pkg/pkg_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT + +package pkg + +import ( + "context" + "go/token" + "sync" + "testing" + + "github.com/issue9/assert/v3" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "github.com/issue9/web/cmd/web/internal/restdoc/logger/loggertest" +) + +type appender struct { + pkgs []*Package + mux sync.Mutex +} + +func (a *appender) append(p *Package) { + a.mux.Lock() + defer a.mux.Unlock() + a.pkgs = append(a.pkgs, p) +} + +func newAppender() *appender { + return &appender{pkgs: make([]*Package, 0, 10)} +} + +func TestScanDir(t *testing.T) { + a := assert.New(t, false) + + ap := newAppender() + fset := token.NewFileSet() + l := loggertest.New() + ScanDir(context.Background(), fset, "./testdir", true, ap.append, l.Logger) + a.Length(ap.pkgs, 2). + Length(l.Entries[logger.Info], 2). + NotNil(ap.pkgs[0].Path, "github.com/issue9/web/cmd/web/internal/restdoc/pkg/testdir"). + NotNil(ap.pkgs[1].Path, "github.com/issue9/web/cmd/web/internal/restdoc/pkg/testdir/testdir2") + + ap = newAppender() + fset = token.NewFileSet() + l = loggertest.New() + ScanDir(context.Background(), fset, "./testdir", false, ap.append, l.Logger) + a.Length(ap.pkgs, 1). + Length(l.Entries[logger.Info], 2). + NotNil(ap.pkgs[0].Path, "github.com/issue9/web/cmd/web/internal/restdoc/pkg/testdir/testdir2") +} + +func TestScan(t *testing.T) { + a := assert.New(t, false) + + l := loggertest.New() + ctx := context.Background() + fset := token.NewFileSet() + + pkg := scan(ctx, fset, l.Logger, "./testdir", "github.com/test/testdata") + a.NotNil(pkg). + Length(pkg.Files, 1). + Equal(pkg.Path, "github.com/test/testdata"). + Zero(l.Count()) +} diff --git a/cmd/web/internal/restdoc/pkg/testdir/testdir.go b/cmd/web/internal/restdoc/pkg/testdir/testdir.go new file mode 100644 index 00000000..20bde698 --- /dev/null +++ b/cmd/web/internal/restdoc/pkg/testdir/testdir.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT + +// Package testdir 测试数据 +package testdir + +type Int int diff --git a/cmd/web/internal/restdoc/pkg/testdir/testdir2/testdir2.go b/cmd/web/internal/restdoc/pkg/testdir/testdir2/testdir2.go new file mode 100644 index 00000000..a925417a --- /dev/null +++ b/cmd/web/internal/restdoc/pkg/testdir/testdir2/testdir2.go @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MIT + +package testdir2 + +type String string diff --git a/cmd/web/internal/restdoc/restdoc.go b/cmd/web/internal/restdoc/restdoc.go new file mode 100644 index 00000000..ec6df3fe --- /dev/null +++ b/cmd/web/internal/restdoc/restdoc.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT + +// Package restdoc 生成 RESTful api 文档 +// +// TODO 文档 +// map 无法指定字段名,转换成空对象, interface{} 则无法转换。 +// 不支持 gopath 模式 +package restdoc + +import ( + "context" + "encoding/json" + "flag" + "io" + "os" + + "github.com/issue9/cmdopt" + "github.com/issue9/localeutil" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "github.com/issue9/web/cmd/web/internal/restdoc/parser" +) + +var ( + title = localeutil.Phrase("gen restdoc") + usage = localeutil.Phrase("restdoc usage") + outputUsage = localeutil.Phrase("set output file") + recursiveUsage = localeutil.Phrase("recursive dir") +) + +const defaultOutput = "./restdoc.json" + +func Init(opt *cmdopt.CmdOpt, p *localeutil.Printer) { + opt.New("doc", title.LocaleString(p), usage.LocaleString(p), func(fs *flag.FlagSet) cmdopt.DoFunc { + o := fs.String("o", defaultOutput, outputUsage.LocaleString(p)) + r := fs.Bool("r", true, recursiveUsage.LocaleString(p)) + ctx := context.Background() + + return func(w io.Writer) error { + l := logger.New(logger.BuildTermHandler(os.Stdout, p)) + doc := parser.New(l) + for _, dir := range fs.Args() { + doc.AddDir(ctx, dir, *r) + } + + t := doc.OpenAPI(ctx) + + data, err := json.Marshal(t) + if err != nil { + return err + } + return os.WriteFile(*o, data, os.ModePerm) + } + }) +} diff --git a/cmd/web/internal/restdoc/schema/error.go b/cmd/web/internal/restdoc/schema/error.go new file mode 100644 index 00000000..1d842708 --- /dev/null +++ b/cmd/web/internal/restdoc/schema/error.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +package schema + +import ( + "fmt" + "go/token" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" +) + +type Error struct { + Type logger.Type + Msg any + Pos token.Pos +} + +func newError(t logger.Type, pos token.Pos, msg any) *Error { + return &Error{Type: t, Msg: msg, Pos: pos} +} + +func (err *Error) Error() string { return fmt.Sprint(err.Msg) } diff --git a/cmd/web/internal/restdoc/schema/path.go b/cmd/web/internal/restdoc/schema/path.go new file mode 100644 index 00000000..71030fc6 --- /dev/null +++ b/cmd/web/internal/restdoc/schema/path.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +package schema + +import ( + "regexp" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// 根据 t 生成路径参数的 Schema +// +// 如果 t 的类型无法解析,则会尝试将其作为正则进行处理,如果还是不行则返回错误。 +func NewPath(t string) (*openapi3.SchemaRef, error) { + // NOTE: 都是基本类型,ref 都直接为空。 + + switch strings.ToLower(t) { + case "int", "integer": + return openapi3.NewSchemaRef("", openapi3.NewInt64Schema()), nil + case "bool", "boolean": + return openapi3.NewSchemaRef("", openapi3.NewBoolSchema()), nil + case "string", "str": + return openapi3.NewSchemaRef("", openapi3.NewStringSchema()), nil + case "number", "float", "float32", "float64": + return openapi3.NewSchemaRef("", openapi3.NewFloat64Schema()), nil + case "id": + var id float64 = 1 + schema := openapi3.NewInt64Schema() + schema.Min = &id + return openapi3.NewSchemaRef("", schema), nil + default: + if _, err := regexp.Compile(t); err != nil { + return nil, err + } + return openapi3.NewSchemaRef("", openapi3.NewSchema().WithPattern(t)), nil + } +} diff --git a/cmd/web/internal/restdoc/schema/path_test.go b/cmd/web/internal/restdoc/schema/path_test.go new file mode 100644 index 00000000..d44a17d8 --- /dev/null +++ b/cmd/web/internal/restdoc/schema/path_test.go @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT + +package schema + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/issue9/assert/v3" +) + +func TestNewPathSchema(t *testing.T) { + a := assert.New(t, false) + s, err := NewPath("int") + a.NotError(err).NotNil(s).Empty(s.Ref).Equal(s.Value.Type, openapi3.TypeInteger) + + s, err = NewPath("boolean") + a.NotError(err).NotNil(s).Empty(s.Ref).Equal(s.Value.Type, openapi3.TypeBoolean) + + s, err = NewPath("str") + a.NotError(err).NotNil(s).Empty(s.Ref).Equal(s.Value.Type, openapi3.TypeString) + + s, err = NewPath("float32") + a.NotError(err).NotNil(s).Empty(s.Ref).Equal(s.Value.Type, openapi3.TypeNumber) + + s, err = NewPath("id") + a.NotError(err).NotNil(s).Empty(s.Ref). + Equal(s.Value.Type, openapi3.TypeInteger). + Equal(*s.Value.Min, 1) + + s, err = NewPath("\\s+") + a.NotError(err).NotNil(s).Empty(s.Ref). + Empty(s.Value.Type). + Equal(s.Value.Pattern, "\\s+") + + s, err = NewPath("(\\)+") // 无效的正则表达式 + a.Error(err).Nil(s) +} diff --git a/cmd/web/internal/restdoc/schema/schema.go b/cmd/web/internal/restdoc/schema/schema.go new file mode 100644 index 00000000..6d927c71 --- /dev/null +++ b/cmd/web/internal/restdoc/schema/schema.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT + +// Package schema 将 ast 转换铖 openapi 的 schema 对象 +package schema + +import "github.com/getkin/kin-openapi/openapi3" + +// NewOpenAPI 声明基本的 openapi3.T 对象 +func NewOpenAPI() *openapi3.T { + c := openapi3.NewComponents() + c.Schemas = make(openapi3.Schemas) + c.Responses = make(openapi3.Responses) + c.SecuritySchemes = make(openapi3.SecuritySchemes) + + t := &openapi3.T{ + OpenAPI: "3", + Components: &c, + } + + return t +} diff --git a/cmd/web/internal/restdoc/schema/search.go b/cmd/web/internal/restdoc/schema/search.go new file mode 100644 index 00000000..3f82f46a --- /dev/null +++ b/cmd/web/internal/restdoc/schema/search.go @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: MIT + +package schema + +import ( + "fmt" + "go/ast" + "go/token" + "path" + "reflect" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/issue9/localeutil" + "github.com/issue9/query/v3" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger" + "github.com/issue9/web/cmd/web/internal/restdoc/pkg" + "github.com/issue9/web/cmd/web/internal/restdoc/utils" +) + +type SearchFunc func(string) *pkg.Package + +// currPath 当前包的导出路径; +// typeName 表示需要查找的类型名,非内置类型且不带路径信息,则将 currPath 作为路径信息。 +// q 是否用于查询参数 +// +// 可能返回的错误值为 *Error +func (f SearchFunc) New(t *openapi3.T, currPath, typeName string, q bool) (*openapi3.SchemaRef, error) { + var isArray bool + if strings.HasPrefix(typeName, "[]") { + typeName = typeName[2:] + isArray = true + } + + tag := "json" + if q { + tag = query.Tag + } + + return f.fromName(t, currPath, typeName, tag, isArray) +} + +// 根据类型名生成 schema 对象 +// +// 参数参考 [SearchFunc.New] +func (f SearchFunc) fromName(t *openapi3.T, currPath, typeName, tag string, isArray bool) (*openapi3.SchemaRef, error) { + switch typeName { // 基本类型 + case "int", "int8", "int16", "int32", "int64", + "uint", "uint8", "uint16", "uint32", "uint64": + return array(openapi3.NewSchemaRef("", openapi3.NewIntegerSchema()), isArray), nil + case "float32", "float64": + return array(openapi3.NewSchemaRef("", openapi3.NewFloat64Schema()), isArray), nil + case "bool": + return array(openapi3.NewSchemaRef("", openapi3.NewBoolSchema()), isArray), nil + case "string": + return array(openapi3.NewSchemaRef("", openapi3.NewStringSchema()), isArray), nil + case "map": + return array(openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), isArray), nil + } + + modPath := currPath + modName := typeName + if index := strings.LastIndexByte(typeName, '.'); index > 0 { // 全局的路径 + modPath = typeName[:index] + modName = typeName[index+1:] + } else { + typeName = currPath + "." + typeName + } + if modPath == "" { + return nil, localeutil.Error("无法找到 %s", typeName) // 行数未变化,直接返回错误。 + } + + if ref, found := t.Components.Schemas[typeName]; found { // 查找是否已经存在于 components/schemes + return array(ref, isArray), nil + } + + pkg := f(modPath) + if pkg == nil { + return nil, localeutil.Error("未找到 %s 表示的对象", modPath) // 行数未变化,直接返回错误。 + } + + var spec *ast.TypeSpec + var file *ast.File +LOOP: + for _, f := range pkg.Files { + for _, d := range f.Decls { + gen, ok := d.(*ast.GenDecl) + if !ok || gen.Tok != token.TYPE { + continue + } + + for _, s := range gen.Specs { + if spec, ok = s.(*ast.TypeSpec); ok && spec.Name.Name == modName { + file = f + break LOOP // 找到了,就退到最外层。 + } + } + } + } + + if spec == nil { + return nil, localeutil.Error("无法找到 %s", typeName) + } + + ref, err := f.fromTypeSpec(t, file, currPath, typeName, tag, spec) + if err != nil { + return nil, err + } + + if ref.Ref != "" { + t.Components.Schemas[ref.Ref] = ref + } + return array(ref, isArray), nil +} + +// 将 ast.TypeSpec 转换成 openapi3.Schema +// +// typeName 仅用于生成 SchemaRef.Ref 值,需要完整路径。 +func (f SearchFunc) fromTypeSpec(t *openapi3.T, file *ast.File, currPath, typeName, tag string, s *ast.TypeSpec) (*openapi3.SchemaRef, error) { + desc, enums := parseTypeDoc(s) + if desc == "" && s.Comment != nil { + desc = s.Comment.Text() + } + + switch ts := s.Type.(type) { + case *ast.Ident: // type x = int 或是 type x int + ref, err := f.fromName(t, currPath, ts.Name, tag, false) + if err != nil { + return nil, newError(logger.DocSyntax, s.Pos(), err) + } + ref.Value.Description = desc + ref.Value.Enum = enums + ref.Ref = typeName + return ref, nil + case *ast.SelectorExpr: // type x = json.Decoder 或是 type x json.Decoder 引用外部对象 + name := getSelectorExprTypeName(ts, file) + ref, err := f.fromName(t, currPath, name, tag, false) + if err != nil { + return nil, newError(logger.DocSyntax, s.Pos(), err) + } + ref.Value.Description = desc + ref.Value.Enum = enums + return ref, nil + case *ast.StructType: + schema := openapi3.NewObjectSchema() + schema.Description = desc + schema.Enum = enums + + if err := f.addFields(t, file, schema, currPath, tag, ts.Fields.List); err != nil { + return nil, err + } + + return openapi3.NewSchemaRef(typeName, schema), nil + default: + msg := fmt.Sprintf("未知的错误 %s.Type 无法转换成 ast.StructType", s.Type) + return nil, newError(logger.DocSyntax, s.Pos(), msg) + } +} + +func parseTypeDoc(s *ast.TypeSpec) (desc string, enums []any) { + if s.Doc == nil { + return "", nil + } + text := s.Doc.Text() + if text == "" { + return "", nil + } + + // @enum e1 e2 e3 + lines := strings.Split(text, "\n") + for _, line := range lines { + if tag, suffix := utils.CutTag(line); tag == "@enum" { + for _, word := range utils.SplitSpace(suffix) { + enums = append(enums, word) + } + } + } + + return text, enums +} + +// 将 list 中的所有字段解析到 schema +// +// 字段名如果存在 json 时,取 json 名称,否则直接采用字段名,xml 仅采用了 attr 和 parent>child 两种格式。 +func (f SearchFunc) addFields(t *openapi3.T, file *ast.File, s *openapi3.Schema, modPath, tagName string, list []*ast.Field) error { +LOOP: + for _, field := range list { + if len(field.Names) == 0 { // 嵌套对象 + ref, err := f.fromExpr(t, file, modPath, tagName, field.Type) + if err != nil { + return err + } + + for k, v := range ref.Value.Properties { + s.WithPropertyRef(k, v) + } + continue + } + + name, nullable, xml := parseTag(field, tagName) + if name == "-" { + continue LOOP + } + + item, err := f.fromExpr(t, file, modPath, tagName, field.Type) + if err != nil { + return err + } + + var desc string + if field.Doc != nil { + desc = field.Doc.Text() + } + if desc == "" && field.Comment != nil { + desc = field.Comment.Text() + } + + s.WithPropertyRef(name, wrap(item, desc, xml, nullable)) + } + + return nil +} + +// 将 s 中的内容转换到 schema 上 +func (f SearchFunc) fromExpr(t *openapi3.T, file *ast.File, currPath, tag string, e ast.Expr) (*openapi3.SchemaRef, error) { + switch expr := e.(type) { + case *ast.ArrayType: + schema, err := f.fromExpr(t, file, currPath, tag, expr.Elt) + if err != nil { + return nil, err + } + return array(schema, true), nil + case *ast.MapType: // NOTE: map 无法指定字段名 + return openapi3.NewSchemaRef("", openapi3.NewObjectSchema()), nil + case *ast.Ident: + ref, err := f.fromName(t, currPath, expr.Name, tag, false) + if err != nil { + return nil, newError(logger.DocSyntax, e.Pos(), err) + } + return ref, nil + case *ast.StarExpr: // 指针 + return f.fromExpr(t, file, currPath, tag, expr.X) + case *ast.SelectorExpr: + name := getSelectorExprTypeName(expr, file) + ref, err := f.fromName(t, currPath, name, tag, false) + if err != nil { + if _, ok := err.(*Error); ok { + return nil, err + } + return nil, newError(logger.DocSyntax, e.Pos(), err) + } + return ref, nil + //case *ast.InterfaceType: // 无法处理此类型 + default: + msg := fmt.Sprintf("无法处理的类型 %s", expr) + return nil, newError(logger.DocSyntax, e.Pos(), msg) + } +} + +func getSelectorExprTypeName(expr *ast.SelectorExpr, file *ast.File) string { + pkgName := expr.X.(*ast.Ident).Name + for _, d := range file.Imports { + p := strings.Trim(d.Path.Value, "\"") + + var name string + if d.Name != nil { + name = d.Name.Name + } else { + name = path.Base(p) + } + + if name == pkgName { + pkgName = p + break + } + } + return pkgName + "." + expr.Sel.Name +} + +func parseTag(field *ast.Field, tagName string) (name string, nullable bool, xml *openapi3.XML) { + name = field.Names[0].Name + if !token.IsExported(name) { // 不能导出的字段自动忽略 + return "-", false, nil + } + + if field.Tag != nil { + structTag := reflect.StructTag(strings.Trim(field.Tag.Value, "`")) + tag := structTag.Get(tagName) + if tag == "-" { // 忽略此字段 + return "-", false, nil + } + + if tag != "" { + words := strings.Split(tag, ",") + name = words[0] + if len(words) > 1 && words[1] == "omitempty" { + nullable = true + } + } + + if tagName != query.Tag { // 非查询参数对象,需要处理 XML 的特殊情况 + tag := structTag.Get("xml") + if tag != "" && tag != "-" { + words := strings.Split(tag, ",") + switch len(words) { + case 1: + if strings.IndexByte(words[0], '>') > 0 { + xml = &openapi3.XML{Wrapped: true} + } + case 2: + wrap := strings.IndexByte(words[0], '>') > 0 + attr := words[1] == "attr" + if wrap || attr { + xml = &openapi3.XML{Wrapped: wrap, Attribute: attr} + } + } + } + } + } + return +} + +// 根据 isArray 将 ref 包装成相应的对象 +func array(ref *openapi3.SchemaRef, isArray bool) *openapi3.SchemaRef { + if !isArray { + return ref + } + + s := openapi3.NewArraySchema() + s.Items = ref + return openapi3.NewSchemaRef("", s) +} + +// 将从 components/schemas 中获取的对象进行二次包装 +func wrap(ref *openapi3.SchemaRef, desc string, xml *openapi3.XML, nullable bool) *openapi3.SchemaRef { + if ref.Value.Nullable != nullable || + ref.Value.XML != xml || + (desc != "" && ref.Value.Description != desc) { + s := openapi3.NewSchema() + s.AllOf = openapi3.SchemaRefs{ref} + s.Nullable = nullable + s.XML = xml + if desc != "" { + s.Description = desc + } + ref = openapi3.NewSchemaRef("", s) + } + return ref +} diff --git a/cmd/web/internal/restdoc/schema/search_test.go b/cmd/web/internal/restdoc/schema/search_test.go new file mode 100644 index 00000000..426e65ce --- /dev/null +++ b/cmd/web/internal/restdoc/schema/search_test.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT + +package schema + +import ( + "context" + "go/token" + "sync" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/issue9/assert/v3" + "github.com/issue9/sliceutil" + + "github.com/issue9/web/cmd/web/internal/restdoc/logger/loggertest" + "github.com/issue9/web/cmd/web/internal/restdoc/pkg" +) + +func buildSearchFunc(a *assert.Assertion) SearchFunc { + ctx := context.Background() + fset := token.NewFileSet() + l := loggertest.New() + + var pkgs []*pkg.Package + var pkgsM sync.Mutex + af := func(p *pkg.Package) { + pkgsM.Lock() + defer pkgsM.Unlock() + pkgs = append(pkgs, p) + } + pkg.ScanDir(ctx, fset, "./testdata", true, af, l.Logger) + + return func(s string) *pkg.Package { + pkgsM.Lock() + defer pkgsM.Unlock() + + r, found := sliceutil.At(pkgs, func(pkg *pkg.Package) bool { return pkg.Path == s }) + if found { + return r + } + return nil + } +} + +func TestSearchFunc_NewSchema(t *testing.T) { + a := assert.New(t, false) + f := buildSearchFunc(a) + modPath := "github.com/issue9/web/cmd/web/internal/restdoc/schema/testdata" + + t.Run("[]bool", func(t *testing.T) { + a := assert.New(t, false) + tt := NewOpenAPI() + + ref, err := f.New(tt, modPath, "[]bool", false) + a.NotError(err).NotNil(ref). + Empty(ref.Value.Description). + Equal(ref.Value.Type, openapi3.TypeArray). + Equal(ref.Value.Items.Value.Type, openapi3.TypeBoolean) + }) + + // 枚举数组 + t.Run("[]Sex", func(t *testing.T) { + a := assert.New(t, false) + tt := NewOpenAPI() + + ref, err := f.New(tt, modPath, "[]Sex", false) + a.NotError(err).NotNil(ref). + Equal(ref.Value.Type, openapi3.TypeArray). + Equal(ref.Value.Items.Ref, modPath+".Sex") + + sex := tt.Components.Schemas[modPath+".Sex"] + a.NotNil(sex). + Equal(sex.Value.Description, "Sex 表示性别\n@enum female male unknown\n"). + Equal(sex.Value.Type, openapi3.TypeInteger). + Equal(sex.Value.Enum, []string{"female", "male", "unknown"}) + }) + + // 对象数组 + t.Run("[]User", func(t *testing.T) { + a := assert.New(t, false) + tt := NewOpenAPI() + + ref, err := f.New(tt, modPath, "[]User", false) + a.NotError(err).NotNil(ref). + Equal(ref.Value.Type, openapi3.TypeArray). + Equal(ref.Value.Items.Ref, modPath+".User") + u := tt.Components.Schemas[modPath+".User"] + a.NotNil(u). + Equal(u.Value.Description, "用户信息 doc\n"). + Equal(u.Value.Type, openapi3.TypeObject) + + name := u.Value.Properties["Name"] + a.Equal(name.Value.AllOf[0].Ref, modPath+".String"). + Equal(name.Value.Description, "姓名\n") + + sex := u.Value.Properties["sex"] + a.Equal(sex.Value.AllOf[0].Ref, modPath+".Sex"). + True(sex.Value.XML.Attribute). + Equal(sex.Value.Description, "性别\n") + + age := u.Value.Properties["age"] + a.Empty(age.Ref). + Equal(age.Value.Description, "年龄\n"). + Equal(age.Value.AllOf[0].Value.Type, openapi3.TypeInteger) + }) + + // admin.User + t.Run("admin.User", func(t *testing.T) { + a := assert.New(t, false) + tt := NewOpenAPI() + + ref, err := f.New(tt, modPath, modPath+"/admin.User", false) + a.NotError(err).NotNil(ref). + Equal(ref.Ref, modPath+".User") + }) + + // admin.Admin + t.Run("admin.Admin", func(t *testing.T) { + a := assert.New(t, false) + tt := NewOpenAPI() + + ref, err := f.New(tt, modPath, modPath+"/admin.Admin", false) + a.NotError(err).NotNil(ref). + Equal(ref.Value.Type, openapi3.TypeObject) + + admin := tt.Components.Schemas[modPath+"/admin.Admin"] + name := admin.Value.Properties["Name"] + a.Equal(name.Value.AllOf[0].Ref, modPath+".String"). + Equal(name.Value.Description, "姓名\n") + + u1 := admin.Value.Properties["U1"] + a.Empty(u1.Ref). + Equal(u1.Value.Description, "u1\n"). + Equal(u1.Value.AllOf[0].Value.Type, openapi3.TypeArray). + Equal(u1.Value.AllOf[0].Value.Items.Ref, modPath+".User") + + u2 := admin.Value.Properties["u2"] + a.Empty(u2.Ref). + Equal(u2.Value.Description, "u2\n"). + True(u2.Value.Nullable). + Equal(u2.Value.AllOf[0].Ref, modPath+".User") + + u3 := admin.Value.Properties["u3"] + a.Nil(u3) + + u4 := admin.Value.Properties["U4"] + a.Equal(u4.Ref, modPath+".User") + }) +} + +func TestWrap(t *testing.T) { + a := assert.New(t, false) + + ref := openapi3.NewSchemaRef("ref", openapi3.NewBoolSchema()) + ref2 := wrap(ref, "", nil, false) + a.Equal(ref2, ref) + + ref2 = wrap(ref, "123", nil, false) + a.NotEqual(ref2, ref). + Equal(ref2.Value.AllOf[0].Value, ref.Value). + Equal(ref2.Value.Description, "123") + + ref2 = wrap(ref, "123", nil, true) + a.NotEqual(ref2, ref). + Equal(ref2.Value.AllOf[0].Value, ref.Value). + Equal(ref2.Value.Description, "123"). + True(ref2.Value.Nullable) +} diff --git a/cmd/web/internal/restdoc/schema/testdata/admin/admin.go b/cmd/web/internal/restdoc/schema/testdata/admin/admin.go new file mode 100644 index 00000000..25b5b114 --- /dev/null +++ b/cmd/web/internal/restdoc/schema/testdata/admin/admin.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT + +// Package admin 测试用例 +package admin + +import "github.com/issue9/web/cmd/web/internal/restdoc/schema/testdata" + +type User testdata.User + +type Admin struct { + testdata.User // User + U1 []*testdata.User // u1 + U2 testdata.User `json:"u2,omitempty"` // u2 + u3 testdata.User + U4 User +} diff --git a/cmd/web/internal/restdoc/schema/testdata/schema.go b/cmd/web/internal/restdoc/schema/testdata/schema.go new file mode 100644 index 00000000..4442d3cf --- /dev/null +++ b/cmd/web/internal/restdoc/schema/testdata/schema.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT + +// Package testdata 测试 schema 的生成 +package testdata + +type ( + String string + + // Sex 表示性别 + // @enum female male unknown + Sex int8 + + // 用户信息 doc + User struct { // 用户信息 comment + // 姓名 + Name String + + // 年龄 + Age int `xml:"age,attr" json:"age"` + Sex Sex `json:"sex" xml:"sex,attr"` // 性别 + } +) diff --git a/cmd/web/internal/restdoc/utils/utils.go b/cmd/web/internal/restdoc/utils/utils.go new file mode 100644 index 00000000..24e7ca41 --- /dev/null +++ b/cmd/web/internal/restdoc/utils/utils.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT + +// Package utils 公用方法 +package utils + +import ( + "strings" + "unicode" +) + +func IsEmail(s string) bool { + return !IsURL(s) && strings.IndexByte(s, '@') > 0 +} + +func IsURL(s string) bool { + s = strings.ToLower(s) + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// CutTag 从第一个空格处截为两段 +func CutTag(line string) (tag, suffix string) { + words, _ := SplitSpaceN(line, 2) + if len(words) != 2 { + panic(line) + } + return words[0], words[1] +} + +// SplitSpaceN 以空格分隔字符串 +// +// maxSize 表示最多分隔的数量,如果无法达到 maxSize 的数量,则采用空字符串代替剩余的元素, +// 返回值 length 表示实际的元素数量。-1 表示按实际的数量拆分,length 始终等于 len(ret)。 +func SplitSpaceN(s string, maxSize int) (ret []string, length int) { + if maxSize == 0 { + panic("参数 maxSize 不能为 0") + } else if maxSize == 1 { + s = strings.TrimSpace(s) + if s == "" { + return nil, 0 + } + return []string{s}, 1 + } else if maxSize < 0 { + ret = make([]string, 0, 10) + } else { + ret = make([]string, 0, maxSize) + } + + var prevIndex int + prevIsSpace := true + count := -1 + maxSize-- + + for index, c := range s { + if !unicode.IsSpace(c) { + if !prevIsSpace { + continue + } + + prevIndex = index + prevIsSpace = false + count++ + + if maxSize > 0 && count == maxSize { // maxSize <= 1 的情况在开头已经处理过。 + break + } + continue + } + + if !prevIsSpace { // 连续空格中的第一个空格 + prevIsSpace = true + ret = append(ret, s[prevIndex:index]) + prevIndex = index + } + } + + if last := strings.TrimRightFunc(s[prevIndex:], unicode.IsSpace); last != "" { + ret = append(ret, last) + } + + l := len(ret) + if maxSize >= 0 { // 填充空白 + maxSize++ + for maxSize-len(ret) > 0 { + ret = append(ret, "") + } + } + + return ret, l +} + +// SplitSpace 以空格分隔字符串 +func SplitSpace(s string) []string { + ret, _ := SplitSpaceN(s, -1) + return ret +} diff --git a/cmd/web/internal/restdoc/utils/utils_test.go b/cmd/web/internal/restdoc/utils/utils_test.go new file mode 100644 index 00000000..d4992d4d --- /dev/null +++ b/cmd/web/internal/restdoc/utils/utils_test.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT + +package utils + +import ( + "testing" + + "github.com/issue9/assert/v3" +) + +func TestIsEmail(t *testing.T) { + a := assert.New(t, false) + a.True(IsEmail("abc@example.com")) + a.False(IsEmail("@example.com")) + a.False(IsEmail("https://example.com")) + a.False(IsEmail("example.com")) +} + +func TestIsURL(t *testing.T) { + a := assert.New(t, false) + a.True(IsURL("https://example.com")) + a.True(IsURL("http://example.com")) + a.False(IsURL("ftp://example.com")) +} + +func TestCutTag(t *testing.T) { + a := assert.New(t, false) + + tag, suffix := CutTag("@tag tag desc ") + a.Equal(tag, "@tag"). + Equal(suffix, "tag desc") + + tag, suffix = CutTag("@tag ") + a.Equal(tag, "@tag"). + Equal(suffix, "") + + tag, suffix = CutTag("@tag") + a.Equal(tag, "@tag"). + Equal(suffix, "") +} + +func TestSplitSpaceN(t *testing.T) { + a := assert.New(t, false) + + a.PanicString(func() { + SplitSpaceN("", 0) + }, "参数 maxSize 不能为 0") + + s, l := SplitSpaceN("", 1) + a.Equal(l, 0).Nil(s) + + s, l = SplitSpaceN(" ", 1) + a.Equal(l, 0).Nil(s) + + s, l = SplitSpaceN("ab1", 1) + a.Equal(l, 1).Equal(s, []string{"ab1"}) + + s, l = SplitSpaceN("ab3", 3) + a.Equal(l, 1).Equal(s, []string{"ab3", "", ""}) + + s, l = SplitSpaceN(" ab1 ", 1) + a.Equal(l, 1).Equal(s, []string{"ab1"}) + + s, l = SplitSpaceN(" a\tb1 ", 1) + a.Equal(l, 1).Equal(s, []string{"a\tb1"}) + + s, l = SplitSpaceN(" a\tb2 ", 2) + a.Equal(l, 2).Equal(s, []string{"a", "b2"}) + + s, l = SplitSpaceN(" a\tb3 ", 3) + a.Equal(l, 2).Equal(s, []string{"a", "b3", ""}) + + s, l = SplitSpaceN(" aa\tbb1 ", 1) + a.Equal(l, 1).Equal(s, []string{"aa\tbb1"}) + + s, l = SplitSpaceN(" aa\tbb2 ", 2) + a.Equal(l, 2).Equal(s, []string{"aa", "bb2"}) + + s, l = SplitSpaceN(" aa\tbb3 ", 3) + a.Equal(l, 2).Equal(s, []string{"aa", "bb3", ""}) + + s, l = SplitSpaceN(" aa\t \t bb3 ", 3) + a.Equal(l, 2).Equal(s, []string{"aa", "bb3", ""}) + + s, l = SplitSpaceN(" aa\t \t bb4 ", 4) + a.Equal(l, 2).Equal(s, []string{"aa", "bb4", "", ""}) +} + +func TestSplitSpace(t *testing.T) { + a := assert.New(t, false) + + a.Equal(SplitSpace(""), []string{}) + a.Equal(SplitSpace(" "), []string{}) + a.Equal(SplitSpace("ab"), []string{"ab"}) + a.Equal(SplitSpace(" ab "), []string{"ab"}) + a.Equal(SplitSpace(" a\tb "), []string{"a", "b"}) + a.Equal(SplitSpace(" aa\t \t bb "), []string{"aa", "bb"}) +} diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 00000000..24502862 --- /dev/null +++ b/cmd/web/main.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "flag" + "fmt" + "io" + "os" + "runtime" + + "github.com/issue9/cmdopt" + "github.com/issue9/localeutil" + "github.com/issue9/web" + + "github.com/issue9/web/cmd/web/internal/restdoc" +) + +const usageTpl = `框架 github.com/issue9/web 的辅助工具 + +包含了以下子命令: +{{commands}} + +以及以下可用的选项: +{{flags}} + +更多信息可访问 https://github.com/issue9/web 查阅。` + +func main() { + var p *localeutil.Printer + // TODO init p + + opt := cmdopt.New(os.Stdout, flag.ContinueOnError, usageTpl, func(fs *flag.FlagSet) cmdopt.DoFunc { + v := fs.Bool("v", false, localeutil.Phrase("show version").LocaleString(p)) + return func(w io.Writer) error { + if *v { + fmt.Fprintf(w, "web: %s\n", web.Version) + fmt.Fprintf(w, "build with: %s\n", runtime.Version()) + } + + return nil + } + }, buildNotFound(p)) + + restdoc.Init(opt, p) + + if err := opt.Exec(os.Args[1:]); err != nil { + panic(err) + } +} + +func buildNotFound(p *localeutil.Printer) func(string) string { + return func(s string) string { + return localeutil.Phrase("command %s not found", s).LocaleString(p) + } +} From 8885f195a6aae18ed8ef6462caebf251d034d9ec Mon Sep 17 00:00:00 2001 From: caixw Date: Wed, 12 Jul 2023 23:44:08 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20build=20?= =?UTF-8?q?=E5=AD=90=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/web/internal/build/build.go | 36 +++++++++++++++++++++++++++++++++ cmd/web/main.go | 2 ++ 2 files changed, 38 insertions(+) create mode 100644 cmd/web/internal/build/build.go diff --git a/cmd/web/internal/build/build.go b/cmd/web/internal/build/build.go new file mode 100644 index 00000000..d2985128 --- /dev/null +++ b/cmd/web/internal/build/build.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT + +// Package build 提供 build 子命令 +package build + +import ( + "flag" + "io" + "os/exec" + + "github.com/issue9/cmdopt" + "github.com/issue9/localeutil" + "golang.org/x/text/message" +) + +var ( + title = localeutil.Phrase("build go source with version from git tag") + usage = localeutil.Phrase("build usage") +) + +func Init(opt *cmdopt.CmdOpt, p *message.Printer) { + opt.New("build", title.LocaleString(p), usage.LocaleString(p), func(fs *flag.FlagSet) cmdopt.DoFunc { + return func(w io.Writer) error { + // + } + }) +} + +func getLatestTag(src string) (string, error) { + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/cmd/web/main.go b/cmd/web/main.go index 24502862..c31c8ced 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -13,6 +13,7 @@ import ( "github.com/issue9/localeutil" "github.com/issue9/web" + "github.com/issue9/web/cmd/web/internal/build" "github.com/issue9/web/cmd/web/internal/restdoc" ) @@ -43,6 +44,7 @@ func main() { }, buildNotFound(p)) restdoc.Init(opt, p) + build.Init(opt, p) if err := opt.Exec(os.Args[1:]); err != nil { panic(err) From e697c5b7a4525d71450ba69ea17aa5ce36834422 Mon Sep 17 00:00:00 2001 From: caixw Date: Mon, 17 Jul 2023 00:24:16 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refactor(build):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=9B=BF=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/web/go.mod | 2 +- cmd/web/go.sum | 8 +++++++ cmd/web/internal/build/build.go | 35 +++++++++++++++++++++++----- cmd/web/internal/build/build_test.go | 28 ++++++++++++++++++++++ 4 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 cmd/web/internal/build/build_test.go diff --git a/cmd/web/go.mod b/cmd/web/go.mod index 88cc38ae..ff36b49a 100644 --- a/cmd/web/go.mod +++ b/cmd/web/go.mod @@ -5,7 +5,7 @@ go 1.20 require ( github.com/getkin/kin-openapi v0.118.0 github.com/issue9/assert/v3 v3.0.4 - github.com/issue9/cmdopt v0.12.2 + github.com/issue9/cmdopt v0.13.0 github.com/issue9/localeutil v0.16.4 github.com/issue9/query/v3 v3.1.2 github.com/issue9/sliceutil v0.12.0 diff --git a/cmd/web/go.sum b/cmd/web/go.sum index c00aaf7d..dea1dad3 100644 --- a/cmd/web/go.sum +++ b/cmd/web/go.sum @@ -19,6 +19,7 @@ github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tF github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc= github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= @@ -26,6 +27,8 @@ github.com/issue9/assert/v3 v3.0.4 h1:WsYZQ6PQmM/pGFrbkn5GIXjWeVZHv+wcl2829UTX1Q github.com/issue9/assert/v3 v3.0.4/go.mod h1:yft/uaskRpwQTyBT3n1zRl91SR1wNlO4fLZHzOa4bdM= github.com/issue9/cmdopt v0.12.2 h1:gYFT5c/YdhA6yIo+GfGtc2PSd2licLoUWJwPehAUdJY= github.com/issue9/cmdopt v0.12.2/go.mod h1:l//IcugcBwX+vCc2KrgC4ylU6rEzhjQyxno7hWoLCDE= +github.com/issue9/cmdopt v0.13.0 h1:mLaa7R94a6MbCf+1GIt4YgFDW6fdt/nnL0AjgKgf8jQ= +github.com/issue9/cmdopt v0.13.0/go.mod h1:l//IcugcBwX+vCc2KrgC4ylU6rEzhjQyxno7hWoLCDE= github.com/issue9/config v0.3.0 h1:KqCgANEoWAX6yaHd+iF/6NURV/FdDJZqsI7rb3giN2I= github.com/issue9/config v0.3.0/go.mod h1:42vFLF+7PDV5s7z+X+Pm6Ska7l4gAMx9H5uhSPT2A+c= github.com/issue9/conv v1.3.4 h1:v1j/p1lVNW4u1yrbUxxNCb61iTFnF86s+KAwS65MsBs= @@ -58,6 +61,7 @@ github.com/klauspost/compress v1.16.6 h1:91SKEy4K37vkp255cJ8QesJhjyRO0hn9i9G0GoU github.com/klauspost/compress v1.16.6/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -75,6 +79,7 @@ github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -85,12 +90,15 @@ github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo= github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cmd/web/internal/build/build.go b/cmd/web/internal/build/build.go index d2985128..02bc2730 100644 --- a/cmd/web/internal/build/build.go +++ b/cmd/web/internal/build/build.go @@ -4,9 +4,10 @@ package build import ( - "flag" "io" + "os" "os/exec" + "strings" "github.com/issue9/cmdopt" "github.com/issue9/localeutil" @@ -19,11 +20,33 @@ var ( ) func Init(opt *cmdopt.CmdOpt, p *message.Printer) { - opt.New("build", title.LocaleString(p), usage.LocaleString(p), func(fs *flag.FlagSet) cmdopt.DoFunc { - return func(w io.Writer) error { - // - } - }) + opt.NewPlain("build", title.LocaleString(p), usage.LocaleString(p), build) +} + +func build(w io.Writer, args []string) error { + ver, err := getLatestTag(args[len(args)-1]) + if err != nil { + return err + } + + replaceVar(args, ver) + + cmd := exec.Command("go", args...) + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + return cmd.Run() +} + +// 替换变量 +// +// 目前支持以下变量: +// +// - {{version}} +func replaceVar(args []string, ver string) { + for index, arg := range args { + arg = strings.ReplaceAll(arg, "{{version}}", ver) + args[index] = arg + } } func getLatestTag(src string) (string, error) { diff --git a/cmd/web/internal/build/build_test.go b/cmd/web/internal/build/build_test.go new file mode 100644 index 00000000..868502cf --- /dev/null +++ b/cmd/web/internal/build/build_test.go @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT + +package build + +import ( + "testing" + + "github.com/issue9/assert/v3" +) + +func TestReplaceVar(t *testing.T) { + a := assert.New(t, false) + + args := []string{"build", "-o", "out.exe", "-ldflags", "-X=xxx", "./src"} + replaceVar(args, "1.0.0") + a.Equal(args, []string{"build", "-o", "out.exe", "-ldflags", "-X=xxx", "./src"}) + + args = []string{"build", "-o", "out.exe", "-ldflags", "-X={{version}}", "./src"} + replaceVar(args, "1.0.0") + a.Equal(args, []string{"build", "-o", "out.exe", "-ldflags", "-X=1.0.0", "./src"}) +} + +func TestGetLatestTag(t *testing.T) { + a := assert.New(t, false) + + ver, err := getLatestTag("./") + a.NotError(err).NotEmpty(ver) +}