From 30a5d800f700f325ba3d3c4288825f00b5c737c6 Mon Sep 17 00:00:00 2001 From: Ahmad Faiz Kamaludin Date: Mon, 11 May 2026 19:13:05 +0700 Subject: [PATCH 1/6] feat: add InterceptSchema/InterceptProp hooks, fix uint formats, patch reflection edge cases - Add InterceptPropFunc and InterceptPropParams to ReflectorConfig; hook called pre/post per struct field in StructSchema (return ErrSkipProperty to skip) - Add InterceptSchemaFunc and InterceptSchemaParams to ReflectorConfig; hook called pre/post per type in SchemaForType (inline/primitive) and RefSchema (components); pre-call stop=true overrides default schema generation - Add RequiredPropByValidateTag option: marks fields required when validate tag contains "required"; configurable tag name and separator - Fix sanitizeDefName: skip package prefix for types from "main" package - Fix uint32 and uint format from int32 to int64 (uint32 max 4.3B exceeds int32 max 2.1B) - Fix RefSchema pre-hook: assign StructSchema fields onto existing pointer so pre-hook customizations (e.g. Extensions, Description) survive to post-hook - Fix ApplyNullable (3.1+): merge "null" into existing []string type instead of silently skipping when schema.Type is already a multi-type slice - Add tests for all new hooks, RequiredPropByValidateTag, and patched edge cases --- go.work.sum | 50 ++++++ internal/reflect/converter.go | 36 +++- internal/reflect/reflector.go | 45 ++++- internal/reflect/reflector_test.go | 262 +++++++++++++++++++++++++++++ internal/reflect/utils.go | 18 +- openapi/config.go | 32 ++++ option/option_test.go | 10 ++ option/reflector.go | 38 +++++ testdata/basic_data_types.v30.yaml | 8 +- testdata/basic_data_types.v31.yaml | 8 +- testdata/basic_data_types.v32.yaml | 8 +- 11 files changed, 498 insertions(+), 17 deletions(-) diff --git a/go.work.sum b/go.work.sum index 6bddcb3..720dd06 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,32 +1,49 @@ +github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bool64/dev v0.2.43/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +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/cheekybits/is v0.0.0-20150225183255-68e9c0620927 h1:SKI1/fuSdodxmNNyVBR8d7X/HuLnRpvvFO0AgyQk764= github.com/cheekybits/is v0.0.0-20150225183255-68e9c0620927/go.mod h1:h/aW8ynjgkuj+NQRlZcDbAbM1ORAbXjXX77sX7T289U= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= +github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o= github.com/dgraph-io/badger/v2 v2.2007.4/go.mod h1:vSw/ax2qojzbN6eXHIx6KPKtCSHJN/Uz0X0VPruTIhk= +github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de h1:t0UHb5vdojIDUqktM6+xJAfScFBsVpXZmqC9dsgJmeA= github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +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/djherbis/atime v1.1.0 h1:rgwVbP/5by8BvvjBNrbh64Qz33idKT3pSnMSJsxhi0g= github.com/djherbis/atime v1.1.0/go.mod h1:28OF6Y8s3NQWwacXc5eZTsEsiMzp7LF8MbXE+XJPdBE= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.3.2 h1:zlnbNHxumkRvfPWgfXu8RBwyNR1x8wh9cf5PTOCqs9Q= github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= @@ -35,9 +52,13 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= +github.com/kataras/jwt v0.1.12 h1:FHPgTTj5UqjlBye4PA4/oxknCY+kQ9K34XAi8d37glA= github.com/kataras/jwt v0.1.12/go.mod h1:xkimAtDhU/aGlQqjwvgtg+VyuPwMiyZHaY8LJRh0mYo= +github.com/kataras/neffos v0.0.24-0.20240408172741-99c879ba0ede h1:ZnSJQ+ri9x46Yz15wHqSb93Q03yY12XMVLUFDJJ0+/g= github.com/kataras/neffos v0.0.24-0.20240408172741-99c879ba0ede/go.mod h1:i0dtcTbpnw1lqIbojYtGtZlu6gDWPxJ4Xl2eJ6oQ1bE= github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= @@ -46,49 +67,73 @@ github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2 h1:JAEbJn3j/FrhdWA9jW8B5ajsLIjeuEHLi8xE4fk997o= github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mediocregopher/radix/v3 v3.8.1 h1:rOkHflVuulFKlwsLY01/M2cM2tWCjDoETcMqKbAWu1M= github.com/mediocregopher/radix/v3 v3.8.1/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= +github.com/nats-io/nats.go v1.34.1 h1:syWey5xaNHZgicYBemv0nohUPPmaLteiBEUT6Q5+F/4= github.com/nats-io/nats.go v1.34.1/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= +github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= github.com/onsi/ginkgo v1.15.2/go.mod h1:Dd6YFfwBW84ETqqtL0CPyPXillHgY6XhQH3uuCCTr/o= github.com/onsi/gomega v1.11.0/go.mod h1:azGKhqFUon9Vuj0YmTfLSmx0FUwqXYSTl5re8lQLTUg= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/shirou/gopsutil/v3 v3.24.3 h1:eoUGJSmdfLzJ3mxIhmOAhgKEKgQkeOwKpz1NbhVnuPE= github.com/shirou/gopsutil/v3 v3.24.3/go.mod h1:JpND7O217xa72ewWz9zN2eIIkPWsDN/3pl0H8Qt0uwg= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tdewolff/argp v0.0.0-20240126212256-acdb2fb50090 h1:ok0U1tNDp9ICD93bMrZuFtHLwDoW+5nfSfF8e/x36Y0= github.com/tdewolff/argp v0.0.0-20240126212256-acdb2fb50090/go.mod h1:fF+gnKbmf3iMG+ErLiF+orMU/InyZIEnKVVigUjfriw= github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.4.1 h1:/vn0k+RBvwlxEmP5E7SZMqNxPhfMVFEJiykr15/0XKM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI= go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= @@ -101,6 +146,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -113,6 +159,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -134,6 +181,7 @@ golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= @@ -152,9 +200,11 @@ golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/internal/reflect/converter.go b/internal/reflect/converter.go index e85c76f..0532e98 100644 --- a/internal/reflect/converter.go +++ b/internal/reflect/converter.go @@ -2,6 +2,7 @@ package reflect import ( "reflect" + "slices" "github.com/oaswrap/spec/openapi" ) @@ -19,7 +20,11 @@ func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflec if mapped := r.TypeMapping[t]; mapped != nil { t = mapped } + interceptSchema := r.interceptSchemaFn() if schema := r.SchemaFromTypeExposer(t); schema != nil { + if interceptSchema != nil { + interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true}) + } r.ApplyNullable(schema, nullable) if field != nil { r.ApplySchemaTags(schema, *field) @@ -35,6 +40,18 @@ func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflec return schema } + // Pre-hook for inline and primitive types (component types are intercepted inside RefSchema). + if interceptSchema != nil { + preSchema := &openapi.Schema{} + if stop, _ := interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: preSchema}); stop { + r.ApplyNullable(preSchema, nullable) + if field != nil { + r.ApplySchemaTags(preSchema, *field) + } + return preSchema + } + } + var schema *openapi.Schema switch t.Kind() { //nolint:exhaustive // only interested in types supported by OpenAPI case reflect.Bool: @@ -43,9 +60,12 @@ func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflec schema = &openapi.Schema{Type: "integer", Format: "int32"} case reflect.Int64: schema = &openapi.Schema{Type: "integer", Format: "int64"} - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32: + case reflect.Uint8, reflect.Uint16: minVal := 0.0 schema = &openapi.Schema{Type: "integer", Format: "int32", Minimum: &minVal} + case reflect.Uint, reflect.Uint32: + minVal := 0.0 + schema = &openapi.Schema{Type: "integer", Format: "int64", Minimum: &minVal} case reflect.Uint64, reflect.Uintptr: minVal := 0.0 schema = &openapi.Schema{Type: "integer", Format: "int64", Minimum: &minVal} @@ -81,6 +101,9 @@ func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflec default: schema = &openapi.Schema{} } + if interceptSchema != nil { + interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true}) + } r.ApplyNullable(schema, nullable) if field != nil { r.ApplySchemaTags(schema, *field) @@ -114,8 +137,15 @@ func (r *Reflector) ApplyNullable(schema *openapi.Schema, nullable bool) { } return } - if typ, ok := schema.Type.(string); ok && typ != "" { - schema.Type = []string{typ, "null"} + switch typ := schema.Type.(type) { + case string: + if typ != "" { + schema.Type = []string{typ, "null"} + } + case []string: + if !slices.Contains(typ, "null") { + schema.Type = append(typ, "null") + } } } diff --git a/internal/reflect/reflector.go b/internal/reflect/reflector.go index 3effa91..363c3af 100644 --- a/internal/reflect/reflector.go +++ b/internal/reflect/reflector.go @@ -1,6 +1,7 @@ package reflect import ( + "errors" "reflect" "time" @@ -184,7 +185,22 @@ func (r *Reflector) RefSchema(t reflect.Type) *openapi.Schema { } r.Generating[t] = true r.Components[name] = &openapi.Schema{} - r.Components[name] = r.StructSchema(t, "json", false, SchemaInline) + interceptSchema := r.interceptSchemaFn() + if interceptSchema != nil { + if stop, _ := interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: r.Components[name]}); stop { + delete(r.Generating, t) + return &openapi.Schema{Ref: "#/components/schemas/" + name} + } + } + built := r.StructSchema(t, "json", false, SchemaInline) + // Assign onto the existing pointer so pre-hook customizations on non-overlapping fields survive. + // StructSchema only sets Type, Properties, and Required. + r.Components[name].Type = built.Type + r.Components[name].Properties = built.Properties + r.Components[name].Required = built.Required + if interceptSchema != nil { + interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: r.Components[name], Processed: true}) + } delete(r.Generating, t) return &openapi.Schema{Ref: "#/components/schemas/" + name} } @@ -196,6 +212,7 @@ func (r *Reflector) StructSchema( mode SchemaMode, ) *openapi.Schema { schema := &openapi.Schema{Type: "object", Properties: map[string]*openapi.Schema{}} + interceptProp := r.interceptPropFn() ForEachField(t, func(field reflect.StructField) { if IgnoredField(field, nameTag) { return @@ -210,11 +227,37 @@ func (r *Reflector) StructSchema( } name = LowerCamel(field.Name) } + if interceptProp != nil { + if err := interceptProp(openapi.InterceptPropParams{ + Name: name, + Field: field, + ParentSchema: schema, + }); errors.Is(err, openapi.ErrSkipProperty) { + return + } + } prop := r.SchemaForType(field.Type, mode, &field) schema.Properties[name] = prop if BoolTag(field.Tag.Get("required")) { schema.Required = append(schema.Required, name) } + if interceptProp != nil { + if err := interceptProp(openapi.InterceptPropParams{ + Name: name, + Field: field, + PropertySchema: prop, + ParentSchema: schema, + Processed: true, + }); errors.Is(err, openapi.ErrSkipProperty) { + delete(schema.Properties, name) + for i, req := range schema.Required { + if req == name { + schema.Required = append(schema.Required[:i], schema.Required[i+1:]...) + break + } + } + } + } }) if len(schema.Properties) == 0 { schema.Properties = nil diff --git a/internal/reflect/reflector_test.go b/internal/reflect/reflector_test.go index b902c2c..d54f4c2 100644 --- a/internal/reflect/reflector_test.go +++ b/internal/reflect/reflector_test.go @@ -237,3 +237,265 @@ func TestReflector_RequestPartsAndStructSchemaBranches(t *testing.T) { assert.Equal(t, "#/components/schemas/Dst", body.Ref) }) } + +func TestStructSchema_InterceptProp(t *testing.T) { + type Payload struct { + Name string `json:"name"` + Secret string `json:"secret"` + } + + t.Run("PreHookSkipsProperty", func(t *testing.T) { + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptProp: func(params openapi.InterceptPropParams) error { + if !params.Processed && params.Name == "secret" { + return openapi.ErrSkipProperty + } + return nil + }, + }, + } + r := reflect.NewReflector(cfg) + schema := r.SchemaForValue(Payload{}, reflect.SchemaInline) + assert.Contains(t, schema.Properties, "name") + assert.NotContains(t, schema.Properties, "secret") + }) + + t.Run("PostHookSkipsProperty", func(t *testing.T) { + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptProp: func(params openapi.InterceptPropParams) error { + if params.Processed && params.Name == "secret" { + return openapi.ErrSkipProperty + } + return nil + }, + }, + } + r := reflect.NewReflector(cfg) + schema := r.SchemaForValue(Payload{}, reflect.SchemaInline) + assert.Contains(t, schema.Properties, "name") + assert.NotContains(t, schema.Properties, "secret") + }) + + t.Run("PostHookModifiesPropertySchema", func(t *testing.T) { + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptProp: func(params openapi.InterceptPropParams) error { + if params.Processed && params.Name == "name" { + params.PropertySchema.Description = "intercepted" + } + return nil + }, + }, + } + r := reflect.NewReflector(cfg) + schema := r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.Contains(t, schema.Properties, "name") + assert.Equal(t, "intercepted", schema.Properties["name"].Description) + assert.Empty(t, schema.Properties["secret"].Description) + }) + + t.Run("CallOrderProcessedFalseBeforeTrue", func(t *testing.T) { + var calls []bool + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptProp: func(params openapi.InterceptPropParams) error { + calls = append(calls, params.Processed) + return nil + }, + }, + } + r := reflect.NewReflector(cfg) + r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.Len(t, calls, 4) // 2 fields × (pre + post) + assert.False(t, calls[0]) + assert.True(t, calls[1]) + assert.False(t, calls[2]) + assert.True(t, calls[3]) + }) + + t.Run("PostHookSkipAlsoRemovesFromRequired", func(t *testing.T) { + type WithRequired struct { + Name string `json:"name" required:"true"` + Secret string `json:"secret" required:"true"` + } + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptProp: func(params openapi.InterceptPropParams) error { + if params.Processed && params.Name == "secret" { + return openapi.ErrSkipProperty + } + return nil + }, + }, + } + r := reflect.NewReflector(cfg) + schema := r.SchemaForValue(WithRequired{}, reflect.SchemaInline) + assert.Contains(t, schema.Required, "name") + assert.NotContains(t, schema.Required, "secret") + assert.NotContains(t, schema.Properties, "secret") + }) +} + +func TestReflector_RequiredPropByValidateTag(t *testing.T) { + type Form struct { + Name string `json:"name" validate:"required,min=3"` + Email string `json:"email" validate:"email"` + Age int `json:"age"` + } + + t.Run("DefaultTagMarksRequired", func(t *testing.T) { + r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( + option.RequiredPropByValidateTag(), + ))) + schema := r.SchemaForValue(Form{}, reflect.SchemaInline) + assert.Contains(t, schema.Required, "name") + assert.NotContains(t, schema.Required, "email") + assert.NotContains(t, schema.Required, "age") + }) + + t.Run("CustomTagName", func(t *testing.T) { + type BindingForm struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"email"` + } + r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( + option.RequiredPropByValidateTag("binding"), + ))) + schema := r.SchemaForValue(BindingForm{}, reflect.SchemaInline) + assert.Contains(t, schema.Required, "name") + assert.NotContains(t, schema.Required, "email") + }) + + t.Run("CustomSeparator", func(t *testing.T) { + type PipeForm struct { + Name string `json:"name" validate:"required|min=3"` + Email string `json:"email" validate:"email"` + } + r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( + option.RequiredPropByValidateTag("validate", "|"), + ))) + schema := r.SchemaForValue(PipeForm{}, reflect.SchemaInline) + assert.Contains(t, schema.Required, "name") + assert.NotContains(t, schema.Required, "email") + }) + + t.Run("NoValidateTagNotRequired", func(t *testing.T) { + type Plain struct { + Name string `json:"name"` + } + r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( + option.RequiredPropByValidateTag(), + ))) + schema := r.SchemaForValue(Plain{}, reflect.SchemaInline) + assert.Empty(t, schema.Required) + }) +} + +func TestStructSchema_InterceptSchema(t *testing.T) { + t.Run("PreHookStopReturnCustomSchema", func(t *testing.T) { + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptSchema: func(params openapi.InterceptSchemaParams) (bool, error) { + if !params.Processed && params.Type == std_reflect.TypeFor[int]() { + params.Schema.Type = "string" + params.Schema.Format = "uuid" + return true, nil + } + return false, nil + }, + }, + } + r := reflect.NewReflector(cfg) + schema := r.SchemaForValue(0, reflect.SchemaInline) + assert.Equal(t, "string", schema.Type) + assert.Equal(t, "uuid", schema.Format) + }) + + t.Run("PostHookModifiesPrimitiveSchema", func(t *testing.T) { + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptSchema: func(params openapi.InterceptSchemaParams) (bool, error) { + if params.Processed && params.Type == std_reflect.TypeFor[string]() { + params.Schema.Description = "intercepted" + } + return false, nil + }, + }, + } + r := reflect.NewReflector(cfg) + schema := r.SchemaForValue("", reflect.SchemaInline) + assert.Equal(t, "intercepted", schema.Description) + }) + + t.Run("PostHookModifiesComponentSchema", func(t *testing.T) { + type Item struct { + Name string `json:"name"` + } + r := spec.NewRouter(option.WithReflectorConfig( + option.InterceptSchema(func(params openapi.InterceptSchemaParams) (bool, error) { + if params.Processed && params.Type == std_reflect.TypeFor[Item]() { + if params.Schema.Extensions == nil { + params.Schema.Extensions = map[string]any{} + } + params.Schema.Extensions["x-intercepted"] = true + } + return false, nil + }), + )) + r.Get("/items", option.Response(200, Item{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + require.Contains(t, doc.Components.Schemas, "Item") + assert.Equal(t, true, doc.Components.Schemas["Item"].Extensions["x-intercepted"]) + }) + + t.Run("PreHookStopOnComponentSkipsStructSchema", func(t *testing.T) { + type Skipped struct { + Name string `json:"name"` + } + r := spec.NewRouter(option.WithReflectorConfig( + option.InterceptSchema(func(params openapi.InterceptSchemaParams) (bool, error) { + if !params.Processed && params.Type == std_reflect.TypeFor[Skipped]() { + params.Schema.Type = "object" + params.Schema.Description = "custom" + return true, nil + } + return false, nil + }), + )) + r.Get("/", option.Response(200, Skipped{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + require.Contains(t, doc.Components.Schemas, "Skipped") + assert.Equal(t, "custom", doc.Components.Schemas["Skipped"].Description) + assert.Nil(t, doc.Components.Schemas["Skipped"].Properties) // StructSchema was skipped + }) +} + +// Ensure InterceptProp wires through spec.NewRouter to StructSchema. +func TestReflector_InterceptPropViaRouter(t *testing.T) { + _ = spec.NewRouter // import guard + type Item struct { + Name string `json:"name"` + Hidden string `json:"hidden"` + } + r := spec.NewRouter(option.WithReflectorConfig( + option.InterceptProp(func(params openapi.InterceptPropParams) error { + if params.Processed && params.Name == "hidden" { + return openapi.ErrSkipProperty + } + return nil + }), + )) + r.Get("/items", option.Response(200, Item{})) + _, err := r.GenerateSchema("yaml") + require.NoError(t, err) + doc := r.Document() + require.Contains(t, doc.Components.Schemas, "Item") + assert.Contains(t, doc.Components.Schemas["Item"].Properties, "name") + assert.NotContains(t, doc.Components.Schemas["Item"].Properties, "hidden") +} diff --git a/internal/reflect/utils.go b/internal/reflect/utils.go index 47e38b7..273a3c2 100644 --- a/internal/reflect/utils.go +++ b/internal/reflect/utils.go @@ -7,6 +7,8 @@ import ( "regexp" "strings" "unicode" + + "github.com/oaswrap/spec/openapi" ) func (r *Reflector) TypeName(t reflect.Type) string { @@ -41,7 +43,7 @@ func sanitizeDefName(t reflect.Type, defaultDefName, callerPkgPath string) strin return defaultDefName } pkgName := path.Base(t.PkgPath()) - if pkgName == "" { + if pkgName == "" || pkgName == "main" { return defaultDefName } pkgName = strings.ToUpper(pkgName[:1]) + pkgName[1:] @@ -66,6 +68,20 @@ func (r *Reflector) InlineRefs() bool { return r.Config.ReflectorConfig != nil && r.Config.ReflectorConfig.InlineRefs } +func (r *Reflector) interceptPropFn() openapi.InterceptPropFunc { + if r.Config == nil || r.Config.ReflectorConfig == nil { + return nil + } + return r.Config.ReflectorConfig.InterceptProp +} + +func (r *Reflector) interceptSchemaFn() openapi.InterceptSchemaFunc { + if r.Config == nil || r.Config.ReflectorConfig == nil { + return nil + } + return r.Config.ReflectorConfig.InterceptSchema +} + func IndirectType(t reflect.Type) reflect.Type { for t != nil && t.Kind() == reflect.Pointer { t = t.Elem() diff --git a/openapi/config.go b/openapi/config.go index f8ab22b..571332a 100644 --- a/openapi/config.go +++ b/openapi/config.go @@ -1,12 +1,42 @@ package openapi import ( + "errors" "reflect" specui "github.com/oaswrap/spec-ui" "github.com/oaswrap/spec-ui/config" ) +// ErrSkipProperty can be returned from InterceptPropFunc to skip adding the property to the schema. +var ErrSkipProperty = errors.New("skip property") + +// InterceptPropParams defines parameters passed to InterceptPropFunc. +// Called twice per field: before schema generation (Processed=false) and after (Processed=true). +type InterceptPropParams struct { + Name string + Field reflect.StructField + PropertySchema *Schema // nil when Processed=false + ParentSchema *Schema + Processed bool +} + +// InterceptPropFunc intercepts field reflection to control or modify property schemas. +// Return ErrSkipProperty to skip adding the property. +type InterceptPropFunc func(params InterceptPropParams) error + +// InterceptSchemaParams defines parameters passed to InterceptSchemaFunc. +// Called twice per type: before schema generation (Processed=false, empty schema) and after (Processed=true). +type InterceptSchemaParams struct { + Type reflect.Type + Schema *Schema + Processed bool +} + +// InterceptSchemaFunc intercepts type schema generation. +// On the pre-call (Processed=false), return stop=true to skip default processing and use Schema as-is. +type InterceptSchemaFunc func(params InterceptSchemaParams) (stop bool, err error) + const ( // Version300 is OpenAPI 3.0.0. Version300 = "3.0.0" @@ -74,6 +104,8 @@ type ReflectorConfig struct { InlineRefs bool StripDefNamePrefix []string InterceptDefName func(t reflect.Type, defaultDefName string) string + InterceptProp InterceptPropFunc + InterceptSchema InterceptSchemaFunc DefNameCallerPkg string TypeMappings []TypeMapping ParameterTagMapping map[ParameterIn]string diff --git a/option/option_test.go b/option/option_test.go index 7513967..be8dce2 100644 --- a/option/option_test.go +++ b/option/option_test.go @@ -211,6 +211,8 @@ func TestReflectorOptions(t *testing.T) { InterceptDefName(func(_ reflect.Type, _ string) string { return "Intercepted" }), TypeMapping(1, "one"), ParameterTagMapping(openapi.ParameterInQuery, "q"), + InterceptProp(func(_ openapi.InterceptPropParams) error { return nil }), + InterceptSchema(func(_ openapi.InterceptSchemaParams) (bool, error) { return false, nil }), } for _, opt := range opts { @@ -227,6 +229,14 @@ func TestReflectorOptions(t *testing.T) { assert.Equal(t, "one", cfg.TypeMappings[0].Dst) } assert.Equal(t, "q", cfg.ParameterTagMapping[openapi.ParameterInQuery]) + assert.NotNil(t, cfg.InterceptProp) + assert.NotNil(t, cfg.InterceptSchema) + + t.Run("RequiredPropByValidateTagSetsInterceptProp", func(t *testing.T) { + c := &openapi.ReflectorConfig{} + RequiredPropByValidateTag()(c) + assert.NotNil(t, c.InterceptProp) + }) } func TestSecurityOptions(t *testing.T) { diff --git a/option/reflector.go b/option/reflector.go index 587f2dd..df34173 100644 --- a/option/reflector.go +++ b/option/reflector.go @@ -2,6 +2,7 @@ package option import ( "reflect" + "strings" "github.com/oaswrap/spec/openapi" ) @@ -33,6 +34,43 @@ func TypeMapping(src, dst any) ReflectorOption { } } +// InterceptSchema sets callback to intercept schema generation per type. +func InterceptSchema(fn openapi.InterceptSchemaFunc) ReflectorOption { + return func(cfg *openapi.ReflectorConfig) { cfg.InterceptSchema = fn } +} + +// InterceptProp sets callback to intercept property schema generation per field. +func InterceptProp(fn openapi.InterceptPropFunc) ReflectorOption { + return func(cfg *openapi.ReflectorConfig) { cfg.InterceptProp = fn } +} + +// RequiredPropByValidateTag marks properties as required when their validate tag contains "required". +// Optional args: tags[0] overrides the tag name (default "validate"), tags[1] overrides the separator (default ","). +func RequiredPropByValidateTag(tags ...string) ReflectorOption { + return InterceptProp(func(params openapi.InterceptPropParams) error { + if !params.Processed { + return nil + } + validateTag := "validate" + sep := "," + if len(tags) > 0 { + validateTag = tags[0] + } + if len(tags) > 1 { + sep = tags[1] + } + if v, ok := params.Field.Tag.Lookup(validateTag); ok { + for _, part := range strings.Split(v, sep) { + if strings.TrimSpace(part) == "required" { + params.ParentSchema.Required = append(params.ParentSchema.Required, params.Name) + break + } + } + } + return nil + }) +} + // ParameterTagMapping overrides tag source for a specific parameter location. func ParameterTagMapping(in openapi.ParameterIn, sourceTag string) ReflectorOption { return func(cfg *openapi.ReflectorConfig) { diff --git a/testdata/basic_data_types.v30.yaml b/testdata/basic_data_types.v30.yaml index 6ed6bbe..e37f838 100644 --- a/testdata/basic_data_types.v30.yaml +++ b/testdata/basic_data_types.v30.yaml @@ -72,7 +72,7 @@ components: type: string uint: type: integer - format: int32 + format: int64 minimum: 0.0 uint16: type: integer @@ -80,7 +80,7 @@ components: minimum: 0.0 uint32: type: integer - format: int32 + format: int64 minimum: 0.0 uint64: type: integer @@ -142,7 +142,7 @@ components: nullable: true uint: type: integer - format: int32 + format: int64 nullable: true minimum: 0.0 uint16: @@ -152,7 +152,7 @@ components: minimum: 0.0 uint32: type: integer - format: int32 + format: int64 nullable: true minimum: 0.0 uint64: diff --git a/testdata/basic_data_types.v31.yaml b/testdata/basic_data_types.v31.yaml index 740313b..bdca3fb 100644 --- a/testdata/basic_data_types.v31.yaml +++ b/testdata/basic_data_types.v31.yaml @@ -72,7 +72,7 @@ components: type: string uint: type: integer - format: int32 + format: int64 minimum: 0.0 uint16: type: integer @@ -80,7 +80,7 @@ components: minimum: 0.0 uint32: type: integer - format: int32 + format: int64 minimum: 0.0 uint64: type: integer @@ -155,7 +155,7 @@ components: type: - integer - 'null' - format: int32 + format: int64 minimum: 0.0 uint16: type: @@ -167,7 +167,7 @@ components: type: - integer - 'null' - format: int32 + format: int64 minimum: 0.0 uint64: type: diff --git a/testdata/basic_data_types.v32.yaml b/testdata/basic_data_types.v32.yaml index 3629d88..2bbcf95 100644 --- a/testdata/basic_data_types.v32.yaml +++ b/testdata/basic_data_types.v32.yaml @@ -72,7 +72,7 @@ components: type: string uint: type: integer - format: int32 + format: int64 minimum: 0.0 uint16: type: integer @@ -80,7 +80,7 @@ components: minimum: 0.0 uint32: type: integer - format: int32 + format: int64 minimum: 0.0 uint64: type: integer @@ -155,7 +155,7 @@ components: type: - integer - 'null' - format: int32 + format: int64 minimum: 0.0 uint16: type: @@ -167,7 +167,7 @@ components: type: - integer - 'null' - format: int32 + format: int64 minimum: 0.0 uint64: type: From 9347a9b5a4d310da6bf44b20da462b9f06d25821 Mon Sep 17 00:00:00 2001 From: Ahmad Faiz Kamaludin Date: Mon, 11 May 2026 19:16:16 +0700 Subject: [PATCH 2/6] docs: update README for uint format fix and new reflector hooks - Fix Reflected Go Types table: uint8/uint16 use int32, uint/uint32/uint64/uintptr use int64 - Add InterceptSchema, InterceptProp, RequiredPropByValidateTag to Reflector Configuration section with usage examples and option table - Add new hooks to Features list --- README.md | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8743cc4..8aa7eeb 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,9 @@ Code-first, framework-agnostic OpenAPI 3.x spec builder for Go. Generate docs fr - Low-level typed OpenAPI model for direct field control. - `spec.OneOf` for explicit one-of schemas. - `SchemaExposer` and `StaticSchemaExposer` hooks for custom reflected schemas. +- `InterceptSchema` hook for type-level schema customization and override. +- `InterceptProp` hook for field-level property filtering and modification. +- `RequiredPropByValidateTag` option to derive `required` from `validate` struct tags. --- @@ -424,8 +427,8 @@ type SearchRequest struct { | `bool` | `type: boolean` | | Signed integers (except `int64`) | `type: integer`, `format: int32` | | `int64` | `type: integer`, `format: int64` | -| Unsigned integers (except `uint64`/`uintptr`) | `type: integer`, `format: int32`, `minimum: 0` | -| `uint64`, `uintptr` | `type: integer`, `format: int64`, `minimum: 0` | +| `uint8`, `uint16` | `type: integer`, `format: int32`, `minimum: 0` | +| `uint`, `uint32`, `uint64`, `uintptr` | `type: integer`, `format: int64`, `minimum: 0` | | `float32` | `type: number`, `format: float` | | `float64` | `type: number`, `format: double` | | `string` | `type: string` | @@ -466,6 +469,19 @@ r := spec.NewRouter( option.InterceptDefName(func(t reflect.Type, defaultName string) string { return defaultName }), + option.InterceptSchema(func(params openapi.InterceptSchemaParams) (stop bool, err error) { + if params.Processed { + params.Schema.Extensions = map[string]any{"x-go-type": params.Type.String()} + } + return false, nil + }), + option.InterceptProp(func(params openapi.InterceptPropParams) error { + if params.Processed && params.Field.Tag.Get("internal") == "true" { + return openapi.ErrSkipProperty + } + return nil + }), + option.RequiredPropByValidateTag(), // marks fields required when validate tag contains "required" ), ) ``` @@ -477,6 +493,9 @@ r := spec.NewRouter( | `TypeMapping(src, dst)` | Reflect `src` as if it were `dst`. | | `ParameterTagMapping(in, sourceTag)` | Add a custom tag for a parameter location while keeping the default tag. | | `InterceptDefName(fn)` | Customize schema component names. | +| `InterceptSchema(fn)` | Hook called before and after each type is reflected. Pre-call (`Processed=false`): return `stop=true` to override schema entirely. Post-call (`Processed=true`): modify the built schema. | +| `InterceptProp(fn)` | Hook called before and after each struct field is reflected. Return `openapi.ErrSkipProperty` to exclude the field. | +| `RequiredPropByValidateTag(tag, sep...)` | Mark properties as required when their `validate` tag (or custom tag) contains `"required"`. Default tag: `validate`, default separator: `,`. | --- From c11726325b90dbab0f82cbe679074dadfbc342ac Mon Sep 17 00:00:00 2001 From: Ahmad Faiz Kamaludin Date: Mon, 11 May 2026 19:25:00 +0700 Subject: [PATCH 3/6] chore: update example openapi --- examples/basic/openapi.yaml | 12 +++--- examples/petstore/openapi.yaml | 76 +++++++++++++++++----------------- examples/petstore/router.go | 1 - 3 files changed, 44 insertions(+), 45 deletions(-) diff --git a/examples/basic/openapi.yaml b/examples/basic/openapi.yaml index ba09c75..3d68550 100644 --- a/examples/basic/openapi.yaml +++ b/examples/basic/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.4 +openapi: 3.1.2 info: title: My API version: 1.0.0 @@ -13,14 +13,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/LoginRequest" + $ref: '#/components/schemas/LoginRequest' responses: - "200": + '200': description: OK content: application/json: schema: - $ref: "#/components/schemas/LoginResponse" + $ref: '#/components/schemas/LoginResponse' /api/v1/users/{id}: get: summary: Get user by ID @@ -32,12 +32,12 @@ paths: schema: type: string responses: - "200": + '200': description: OK content: application/json: schema: - $ref: "#/components/schemas/User" + $ref: '#/components/schemas/User' security: - bearerAuth: [] components: diff --git a/examples/petstore/openapi.yaml b/examples/petstore/openapi.yaml index 867e0e8..3e5bf53 100644 --- a/examples/petstore/openapi.yaml +++ b/examples/petstore/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.1.2 info: title: Petstore API description: This is a sample Petstore server. @@ -39,14 +39,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Pet" + $ref: '#/components/schemas/Pet' responses: - "200": + '200': description: OK content: application/json: schema: - $ref: "#/components/schemas/Pet" + $ref: '#/components/schemas/Pet' security: - petstore_auth: - write:pets @@ -61,14 +61,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Pet" + $ref: '#/components/schemas/Pet' responses: - "201": + '201': description: Created content: application/json: schema: - $ref: "#/components/schemas/Pet" + $ref: '#/components/schemas/Pet' security: - petstore_auth: - write:pets @@ -90,14 +90,14 @@ paths: - pending - sold responses: - "200": + '200': description: OK content: application/json: schema: type: array items: - $ref: "#/components/schemas/Pet" + $ref: '#/components/schemas/Pet' security: - petstore_auth: - write:pets @@ -117,14 +117,14 @@ paths: items: type: string responses: - "200": + '200': description: OK content: application/json: schema: type: array items: - $ref: "#/components/schemas/Pet" + $ref: '#/components/schemas/Pet' security: - petstore_auth: - write:pets @@ -144,12 +144,12 @@ paths: type: integer format: int32 responses: - "200": + '200': description: OK content: application/json: schema: - $ref: "#/components/schemas/Pet" + $ref: '#/components/schemas/Pet' security: - petstore_auth: - write:pets @@ -169,7 +169,7 @@ paths: type: integer format: int32 responses: - "200": + '200': description: OK security: - petstore_auth: @@ -193,7 +193,7 @@ paths: schema: type: string responses: - "204": + '204': description: No Content security: - petstore_auth: @@ -218,12 +218,12 @@ paths: schema: type: string responses: - "200": + '200': description: OK content: application/json: schema: - $ref: "#/components/schemas/ApiResponse" + $ref: '#/components/schemas/ApiResponse' security: - petstore_auth: - write:pets @@ -236,7 +236,7 @@ paths: description: Returns a map of status codes to quantities. operationId: getInventory responses: - "200": + '200': description: OK content: application/json: @@ -258,14 +258,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/Order" + $ref: '#/components/schemas/Order' responses: - "201": + '201': description: Created content: application/json: schema: - $ref: "#/components/schemas/Order" + $ref: '#/components/schemas/Order' /store/order/{orderId}: get: tags: @@ -281,13 +281,13 @@ paths: type: integer format: int32 responses: - "200": + '200': description: OK content: application/json: schema: - $ref: "#/components/schemas/Order" - "404": + $ref: '#/components/schemas/Order' + '404': description: Not Found delete: tags: @@ -303,7 +303,7 @@ paths: type: integer format: int32 responses: - "204": + '204': description: No Content /user: post: @@ -316,14 +316,14 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/User" + $ref: '#/components/schemas/User' responses: - "201": + '201': description: Created content: application/json: schema: - $ref: "#/components/schemas/User" + $ref: '#/components/schemas/User' /user/createWithList: post: tags: @@ -337,9 +337,9 @@ paths: schema: type: array items: - $ref: "#/components/schemas/User" + $ref: '#/components/schemas/User' responses: - "201": + '201': description: Created /user/{username}: get: @@ -355,13 +355,13 @@ paths: schema: type: string responses: - "200": + '200': description: OK content: application/json: schema: - $ref: "#/components/schemas/User" - "404": + $ref: '#/components/schemas/User' + '404': description: Not Found put: tags: @@ -404,13 +404,13 @@ paths: username: type: string responses: - "200": + '200': description: OK content: application/json: schema: - $ref: "#/components/schemas/User" - "404": + $ref: '#/components/schemas/User' + '404': description: Not Found delete: tags: @@ -425,7 +425,7 @@ paths: schema: type: string responses: - "204": + '204': description: No Content components: schemas: @@ -491,7 +491,7 @@ components: tags: type: array items: - $ref: "#/components/schemas/Tag" + $ref: '#/components/schemas/Tag' type: type: string Tag: diff --git a/examples/petstore/router.go b/examples/petstore/router.go index b23394b..b53476a 100644 --- a/examples/petstore/router.go +++ b/examples/petstore/router.go @@ -8,7 +8,6 @@ import ( func createRouter() spec.Generator { r := spec.NewRouter( - option.WithOpenAPIVersion("3.0.3"), option.WithTitle("Petstore API"), option.WithDescription("This is a sample Petstore server."), option.WithVersion("1.0.0"), From 36dfcb611289ac6170f1a73ac69b11c7dd482b14 Mon Sep 17 00:00:00 2001 From: Ahmad Faiz Kamaludin Date: Mon, 11 May 2026 19:42:42 +0700 Subject: [PATCH 4/6] fix: fix issue at lint and marshal yaml to read yaml tag --- internal/reflect/converter.go | 9 +++++---- internal/reflect/reflector.go | 3 ++- internal/validate/document_test.go | 26 +++++++------------------- openapi/codec.go | 9 +++++++-- 4 files changed, 21 insertions(+), 26 deletions(-) diff --git a/internal/reflect/converter.go b/internal/reflect/converter.go index 0532e98..0d4e478 100644 --- a/internal/reflect/converter.go +++ b/internal/reflect/converter.go @@ -7,7 +7,7 @@ import ( "github.com/oaswrap/spec/openapi" ) -//nolint:funlen // covers full OpenAPI scalar/collection/struct mapping in one switch for readability. +//nolint:funlen,gocognit // covers full OpenAPI scalar/collection/struct mapping in one switch for readability. func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflect.StructField) *openapi.Schema { nullable := false for t != nil && t.Kind() == reflect.Pointer { @@ -23,7 +23,7 @@ func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflec interceptSchema := r.interceptSchemaFn() if schema := r.SchemaFromTypeExposer(t); schema != nil { if interceptSchema != nil { - interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true}) + _, _ = interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true}) } r.ApplyNullable(schema, nullable) if field != nil { @@ -102,7 +102,7 @@ func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflec schema = &openapi.Schema{} } if interceptSchema != nil { - interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true}) + _, _ = interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true}) } r.ApplyNullable(schema, nullable) if field != nil { @@ -144,7 +144,8 @@ func (r *Reflector) ApplyNullable(schema *openapi.Schema, nullable bool) { } case []string: if !slices.Contains(typ, "null") { - schema.Type = append(typ, "null") + typ = append(typ, "null") + schema.Type = typ } } } diff --git a/internal/reflect/reflector.go b/internal/reflect/reflector.go index 363c3af..24ce4b5 100644 --- a/internal/reflect/reflector.go +++ b/internal/reflect/reflector.go @@ -199,12 +199,13 @@ func (r *Reflector) RefSchema(t reflect.Type) *openapi.Schema { r.Components[name].Properties = built.Properties r.Components[name].Required = built.Required if interceptSchema != nil { - interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: r.Components[name], Processed: true}) + _, _ = interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: r.Components[name], Processed: true}) } delete(r.Generating, t) return &openapi.Schema{Ref: "#/components/schemas/" + name} } +//nolint:gocognit // covers full struct field inspection with parameter/body split logic. func (r *Reflector) StructSchema( t reflect.Type, nameTag string, diff --git a/internal/validate/document_test.go b/internal/validate/document_test.go index 28b1220..ed6bd5f 100644 --- a/internal/validate/document_test.go +++ b/internal/validate/document_test.go @@ -1,7 +1,6 @@ package validate_test import ( - "strings" "testing" "github.com/stretchr/testify/assert" @@ -145,62 +144,51 @@ func TestValidate_Document_OpenAPI312_RejectsEmptyMediaTypesField(t *testing.T) func TestValidateInfo(t *testing.T) { t.Run("Summary30", func(t *testing.T) { errs := validate.ValidateInfo(openapi.Info{Summary: "foo"}, openapi.Version304) - assertValidationErrorsContains(t, errs, "info.summary requires OpenAPI 3.1.x or 3.2.0") + assertHasError(t, errs, "info.summary requires OpenAPI 3.1.x or 3.2.0") }) t.Run("InvalidTOS", func(t *testing.T) { tos := "://bad" errs := validate.ValidateInfo(openapi.Info{TermsOfService: &tos}, openapi.Version312) - assertValidationErrorsContains(t, errs, "info.termsOfService must be a URI") + assertHasError(t, errs, "info.termsOfService must be a URI") }) t.Run("ContactInvalidURL", func(t *testing.T) { errs := validate.ValidateInfo(openapi.Info{ Contact: &openapi.Contact{URL: "://bad"}, }, openapi.Version312) - assertValidationErrorsContains(t, errs, "info.contact.url must be a URI") + assertHasError(t, errs, "info.contact.url must be a URI") }) t.Run("ContactInvalidEmail", func(t *testing.T) { errs := validate.ValidateInfo(openapi.Info{ Contact: &openapi.Contact{Email: "not-an-email"}, }, openapi.Version312) - assertValidationErrorsContains(t, errs, "info.contact.email must be an email address") + assertHasError(t, errs, "info.contact.email must be an email address") }) t.Run("LicenseMissingName", func(t *testing.T) { errs := validate.ValidateInfo(openapi.Info{ License: &openapi.License{}, }, openapi.Version312) - assertValidationErrorsContains(t, errs, "info.license.name is required") + assertHasError(t, errs, "info.license.name is required") }) t.Run("LicenseInvalidURL", func(t *testing.T) { errs := validate.ValidateInfo(openapi.Info{ License: &openapi.License{Name: "MIT", URL: "://bad"}, }, openapi.Version312) - assertValidationErrorsContains(t, errs, "info.license.url must be a URI") + assertHasError(t, errs, "info.license.url must be a URI") }) t.Run("LicenseIdentifier30", func(t *testing.T) { errs := validate.ValidateInfo(openapi.Info{ License: &openapi.License{Name: "MIT", Identifier: "MIT"}, }, openapi.Version304) - assertValidationErrorsContains(t, errs, "info.license.identifier requires OpenAPI 3.1.x or 3.2.0") + assertHasError(t, errs, "info.license.identifier requires OpenAPI 3.1.x or 3.2.0") }) } -func assertValidationErrorsContains(t *testing.T, errs []error, msg string) { - found := false - for _, e := range errs { - if strings.Contains(e.Error(), msg) { - found = true - break - } - } - assert.True(t, found, "expected error %q not found in %v", msg, errs) -} - func TestValidate_Document_AllowsComponentsWithoutPaths(t *testing.T) { r := spec.NewRouter( option.WithOpenAPIVersion(openapi.Version312), diff --git a/openapi/codec.go b/openapi/codec.go index 012e280..a1fdbfd 100644 --- a/openapi/codec.go +++ b/openapi/codec.go @@ -98,7 +98,7 @@ func structToObject(value reflect.Value, mode objectMode) any { if field.Name == "Extensions" || field.Name == "Extra" { continue } - name, omitempty := jsonField(field) + name, omitempty := fieldTag(field, mode) if name == "" { continue } @@ -249,8 +249,13 @@ func appendOrderedFields(fields []orderedField, next ...orderedField) []orderedF return fields } -func jsonField(field reflect.StructField) (string, bool) { +func fieldTag(field reflect.StructField, mode objectMode) (string, bool) { tag := field.Tag.Get("json") + if mode == objectYAML { + if yamlTag := field.Tag.Get("yaml"); yamlTag != "" { + tag = yamlTag + } + } name, opts, _ := strings.Cut(tag, ",") if name == "-" { return "", false From 6df6e6a0c035e43f3bfd5832e4153d6cec733929 Mon Sep 17 00:00:00 2001 From: Ahmad Faiz Kamaludin Date: Mon, 11 May 2026 20:16:07 +0700 Subject: [PATCH 5/6] feat: fix InterceptSchema/InterceptProp hook correctness and error propagation - Chain InterceptSchema and InterceptProp hooks instead of overwriting previous ones - Propagate errors from all hook calls through SchemaForType, StructSchema, RefSchema, SchemaForValue, RequestParts, ParameterSchema, AddRequest, and AddResponse - Add pre-hook coverage for SchemaExposer types in both SchemaForType and SchemaForValue - Clean up r.Components and r.Generating on pre-hook error in RefSchema to prevent stale empty components blocking retry - Add uniqueStrings deduplication on Required to prevent duplicates when both required struct tag and RequiredPropByValidateTag hook are used - Add parentSnapshot restore on ErrSkipProperty in post-hooks to roll back parent schema mutations (AllOf, AnyOf, OneOf, Extensions, Extra) - Pass ParentType to InterceptPropParams for richer hook context --- internal/builder/builder.go | 4 +- internal/builder/operation.go | 15 +- internal/reflect/converter.go | 71 ++++-- internal/reflect/converter_test.go | 3 +- internal/reflect/reflector.go | 219 ++++++++++++++---- internal/reflect/reflector_test.go | 360 +++++++++++++++++++++++++++-- openapi/config.go | 1 + option/reflector.go | 33 ++- 8 files changed, 627 insertions(+), 79 deletions(-) diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 5c63454..f5a643e 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -70,7 +70,9 @@ func (b *Builder) AddOperationTo( } for _, req := range cfg.Requests { - b.AddRequest(op, req) + if err := b.AddRequest(op, req); err != nil { + return validate.Errorf("%s %s request: %w", method, target, err) + } } if len(cfg.Responses) == 0 { op.Responses["default"] = &openapi.Response{Description: "Default response"} diff --git a/internal/builder/operation.go b/internal/builder/operation.go index 4cbbbc9..9f8143d 100644 --- a/internal/builder/operation.go +++ b/internal/builder/operation.go @@ -8,15 +8,18 @@ import ( "github.com/oaswrap/spec/openapi" ) -func (b *Builder) AddRequest(op *openapi.Operation, cu *openapi.ContentUnit) { - params, body := b.Reflector.RequestParts(cu.Structure, ContentType(cu)) +func (b *Builder) AddRequest(op *openapi.Operation, cu *openapi.ContentUnit) error { + params, body, err := b.Reflector.RequestParts(cu.Structure, ContentType(cu)) + if err != nil { + return err + } op.Parameters = append(op.Parameters, params...) ct := ContentType(cu) if body == nil { isDefaultJSON := ct == "application/json" || cu.ContentType == "" if isDefaultJSON && cu.Format == "" && cu.Example == nil && len(cu.Examples) == 0 { - return + return nil } } @@ -44,6 +47,7 @@ func (b *Builder) AddRequest(op *openapi.Operation, cu *openapi.ContentUnit) { } } op.RequestBody.Content[ct] = mt + return nil } func (b *Builder) AddResponse(op *openapi.Operation, cu *openapi.ContentUnit) error { @@ -68,7 +72,10 @@ func (b *Builder) AddResponse(op *openapi.Operation, cu *openapi.ContentUnit) er ct := ContentType(cu) if cu.Structure != nil || cu.ContentType != "" || cu.Example != nil || len(cu.Examples) > 0 { - schema := b.Reflector.SchemaForValue(cu.Structure, reflect.SchemaUseComponent) + schema, err := b.Reflector.SchemaForValue(cu.Structure, reflect.SchemaUseComponent) + if err != nil { + return err + } if schema == nil && cu.ContentType != "" { schema = &openapi.Schema{Type: "string"} } diff --git a/internal/reflect/converter.go b/internal/reflect/converter.go index 0d4e478..2d0fc0c 100644 --- a/internal/reflect/converter.go +++ b/internal/reflect/converter.go @@ -8,47 +8,77 @@ import ( ) //nolint:funlen,gocognit // covers full OpenAPI scalar/collection/struct mapping in one switch for readability. -func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflect.StructField) *openapi.Schema { +func (r *Reflector) SchemaForType( + t reflect.Type, + mode SchemaMode, + field *reflect.StructField, +) (*openapi.Schema, error) { nullable := false for t != nil && t.Kind() == reflect.Pointer { nullable = true t = t.Elem() } if t == nil { - return &openapi.Schema{} + return &openapi.Schema{}, nil } if mapped := r.TypeMapping[t]; mapped != nil { t = mapped } interceptSchema := r.interceptSchemaFn() + //nolint:nestif // exposer path needs pre+post hook with nullable/tag application if schema := r.SchemaFromTypeExposer(t); schema != nil { + // Pre-hook for exposer types: they bypass the standard pre-hook path below. if interceptSchema != nil { - _, _ = interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true}) + preSchema := &openapi.Schema{} + stop, err := interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: preSchema}) + if err != nil { + return nil, err + } + if stop { + r.ApplyNullable(preSchema, nullable) + if field != nil { + r.ApplySchemaTags(preSchema, *field) + } + return preSchema, nil + } + } + if interceptSchema != nil { + params := openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true} + if _, err := interceptSchema(params); err != nil { + return nil, err + } } r.ApplyNullable(schema, nullable) if field != nil { r.ApplySchemaTags(schema, *field) } - return schema + return schema, nil } if mode == SchemaUseComponent && IsComponentType(t) && !r.InlineRefs() { - schema := r.RefSchema(t) + schema, err := r.RefSchema(t) + if err != nil { + return nil, err + } r.ApplyNullable(schema, nullable) if field != nil { r.ApplySchemaTags(schema, *field) } - return schema + return schema, nil } // Pre-hook for inline and primitive types (component types are intercepted inside RefSchema). if interceptSchema != nil { preSchema := &openapi.Schema{} - if stop, _ := interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: preSchema}); stop { + stop, err := interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: preSchema}) + if err != nil { + return nil, err + } + if stop { r.ApplyNullable(preSchema, nullable) if field != nil { r.ApplySchemaTags(preSchema, *field) } - return preSchema + return preSchema, nil } } @@ -84,17 +114,29 @@ func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflec } break } - schema = &openapi.Schema{Type: "array", Items: r.SchemaForType(t.Elem(), SchemaUseComponent, nil)} + items, err := r.SchemaForType(t.Elem(), SchemaUseComponent, nil) + if err != nil { + return nil, err + } + schema = &openapi.Schema{Type: "array", Items: items} case reflect.Map: + addlProps, err := r.SchemaForType(t.Elem(), SchemaUseComponent, nil) + if err != nil { + return nil, err + } schema = &openapi.Schema{ Type: "object", - AdditionalProperties: r.SchemaForType(t.Elem(), SchemaUseComponent, nil), + AdditionalProperties: addlProps, } case reflect.Struct: if IsTime(t) { schema = &openapi.Schema{Type: "string", Format: "date-time"} } else { - schema = r.StructSchema(t, "json", false, mode) + var err error + schema, err = r.StructSchema(t, "json", false, mode) + if err != nil { + return nil, err + } } case reflect.Interface: schema = &openapi.Schema{} @@ -102,13 +144,16 @@ func (r *Reflector) SchemaForType(t reflect.Type, mode SchemaMode, field *reflec schema = &openapi.Schema{} } if interceptSchema != nil { - _, _ = interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true}) + postParams := openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true} + if _, err := interceptSchema(postParams); err != nil { + return nil, err + } } r.ApplyNullable(schema, nullable) if field != nil { r.ApplySchemaTags(schema, *field) } - return schema + return schema, nil } func (r *Reflector) ApplyNullable(schema *openapi.Schema, nullable bool) { diff --git a/internal/reflect/converter_test.go b/internal/reflect/converter_test.go index 82db3c0..2d7c897 100644 --- a/internal/reflect/converter_test.go +++ b/internal/reflect/converter_test.go @@ -56,7 +56,8 @@ func TestConverter_SchemaForType(t *testing.T) { if tt.name == "Interface" { typ = std_reflect.TypeFor[any]() } - schema := r.SchemaForType(typ, reflect.SchemaInline, nil) + schema, err := r.SchemaForType(typ, reflect.SchemaInline, nil) + require.NoError(t, err) if tt.expected != "" { assert.Equal(t, tt.expected, schema.Type) } diff --git a/internal/reflect/reflector.go b/internal/reflect/reflector.go index 24ce4b5..2a8eb91 100644 --- a/internal/reflect/reflector.go +++ b/internal/reflect/reflector.go @@ -3,6 +3,7 @@ package reflect import ( "errors" "reflect" + "slices" "time" "github.com/oaswrap/spec/openapi" @@ -46,16 +47,17 @@ func NewReflector(cfg *openapi.Config) *Reflector { func (r *Reflector) RequestParts( value any, ct string, -) ([]*openapi.Parameter, *openapi.Schema) { +) ([]*openapi.Parameter, *openapi.Schema, error) { t := IndirectType(reflect.TypeOf(value)) if t == nil { - return nil, nil + return nil, nil, nil } if mapped := r.TypeMapping[t]; mapped != nil { t = mapped } if t.Kind() != reflect.Struct || IsTime(t) { - return nil, r.SchemaForType(t, SchemaUseComponent, nil) + schema, err := r.SchemaForType(t, SchemaUseComponent, nil) + return nil, schema, err } var params []*openapi.Parameter @@ -72,16 +74,20 @@ func (r *Reflector) RequestParts( } }) if !hasParam { - return nil, r.SchemaForType(t, SchemaUseComponent, nil) + schema, err := r.SchemaForType(t, SchemaUseComponent, nil) + return nil, schema, err } if !hasBody { - return params, nil + return params, nil, nil + } + body, err := r.StructSchema(t, bodyTag, true, SchemaInline) + if err != nil { + return nil, nil, err } - body := r.StructSchema(t, bodyTag, true, SchemaInline) if len(body.Properties) == 0 { body = nil } - return params, body + return params, body, nil } func (r *Reflector) ParameterField(field reflect.StructField) (string, string, bool) { @@ -133,7 +139,7 @@ func (r *Reflector) ParameterField(field reflect.StructField) (string, string, b } func (r *Reflector) ParameterSchema(field reflect.StructField, in, name string) *openapi.Parameter { - schema := r.SchemaForType(field.Type, SchemaInline, &field) + schema, _ := r.SchemaForType(field.Type, SchemaInline, &field) param := &openapi.Parameter{ Name: name, In: in, @@ -157,52 +163,89 @@ func (r *Reflector) ParameterSchema(field reflect.StructField, in, name string) return param } -func (r *Reflector) SchemaForValue(value any, mode SchemaMode) *openapi.Schema { +func (r *Reflector) SchemaForValue(value any, mode SchemaMode) (*openapi.Schema, error) { if ov, ok := value.(OneOfValue); ok { values := ov.GetValues() schemas := make([]*openapi.Schema, 0, len(values)) for _, item := range values { - schemas = append(schemas, r.SchemaForValue(item, mode)) + s, err := r.SchemaForValue(item, mode) + if err != nil { + return nil, err + } + schemas = append(schemas, s) } - return &openapi.Schema{OneOf: schemas} + return &openapi.Schema{OneOf: schemas}, nil } if schema, ok := value.(*openapi.Schema); ok { - return schema + return schema, nil } + //nolint:nestif // exposer path needs pre+post hook if schema := r.SchemaFromValueExposer(value); schema != nil { - return schema + t := IndirectType(reflect.TypeOf(value)) + interceptSchema := r.interceptSchemaFn() + if interceptSchema != nil { + preSchema := &openapi.Schema{} + stop, err := interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: preSchema}) + if err != nil { + return nil, err + } + if stop { + return preSchema, nil + } + params := openapi.InterceptSchemaParams{Type: t, Schema: schema, Processed: true} + if _, err := interceptSchema(params); err != nil { + return nil, err + } + } + return schema, nil } return r.SchemaForType(IndirectType(reflect.TypeOf(value)), mode, nil) } -func (r *Reflector) RefSchema(t reflect.Type) *openapi.Schema { +func (r *Reflector) RefSchema(t reflect.Type) (*openapi.Schema, error) { name := r.TypeName(t) if _, ok := r.Components[name]; ok { - return &openapi.Schema{Ref: "#/components/schemas/" + name} + return &openapi.Schema{Ref: "#/components/schemas/" + name}, nil } if r.Generating[t] { - return &openapi.Schema{Ref: "#/components/schemas/" + name} + return &openapi.Schema{Ref: "#/components/schemas/" + name}, nil } r.Generating[t] = true r.Components[name] = &openapi.Schema{} interceptSchema := r.interceptSchemaFn() if interceptSchema != nil { - if stop, _ := interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: r.Components[name]}); stop { + stop, err := interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: r.Components[name]}) + if err != nil { delete(r.Generating, t) - return &openapi.Schema{Ref: "#/components/schemas/" + name} + delete(r.Components, name) + return nil, err } + if stop { + delete(r.Generating, t) + return &openapi.Schema{Ref: "#/components/schemas/" + name}, nil + } + } + built, err := r.StructSchema(t, "json", false, SchemaInline) + if err != nil { + delete(r.Generating, t) + delete(r.Components, name) + return nil, err } - built := r.StructSchema(t, "json", false, SchemaInline) // Assign onto the existing pointer so pre-hook customizations on non-overlapping fields survive. // StructSchema only sets Type, Properties, and Required. r.Components[name].Type = built.Type r.Components[name].Properties = built.Properties r.Components[name].Required = built.Required if interceptSchema != nil { - _, _ = interceptSchema(openapi.InterceptSchemaParams{Type: t, Schema: r.Components[name], Processed: true}) + postParams := openapi.InterceptSchemaParams{Type: t, Schema: r.Components[name], Processed: true} + if _, err := interceptSchema(postParams); err != nil { + delete(r.Generating, t) + delete(r.Components, name) + return nil, err + } } delete(r.Generating, t) - return &openapi.Schema{Ref: "#/components/schemas/" + name} + return &openapi.Schema{Ref: "#/components/schemas/" + name}, nil } //nolint:gocognit // covers full struct field inspection with parameter/body split logic. @@ -211,10 +254,15 @@ func (r *Reflector) StructSchema( nameTag string, onlyTagged bool, mode SchemaMode, -) *openapi.Schema { +) (*openapi.Schema, error) { schema := &openapi.Schema{Type: "object", Properties: map[string]*openapi.Schema{}} interceptProp := r.interceptPropFn() + parentType := t + var firstErr error ForEachField(t, func(field reflect.StructField) { + if firstErr != nil { + return + } if IgnoredField(field, nameTag) { return } @@ -233,37 +281,130 @@ func (r *Reflector) StructSchema( Name: name, Field: field, ParentSchema: schema, - }); errors.Is(err, openapi.ErrSkipProperty) { + ParentType: parentType, + }); err != nil { + if errors.Is(err, openapi.ErrSkipProperty) { + return + } + firstErr = err return } } - prop := r.SchemaForType(field.Type, mode, &field) + prop, err := r.SchemaForType(field.Type, mode, &field) + if err != nil { + firstErr = err + return + } schema.Properties[name] = prop if BoolTag(field.Tag.Get("required")) { schema.Required = append(schema.Required, name) } if interceptProp != nil { - if err := interceptProp(openapi.InterceptPropParams{ - Name: name, - Field: field, - PropertySchema: prop, - ParentSchema: schema, - Processed: true, - }); errors.Is(err, openapi.ErrSkipProperty) { - delete(schema.Properties, name) - for i, req := range schema.Required { - if req == name { - schema.Required = append(schema.Required[:i], schema.Required[i+1:]...) - break - } - } + if err := r.runPostHook(interceptProp, schema, prop, name, field, parentType); err != nil { + firstErr = err + return } } }) + if firstErr != nil { + return nil, firstErr + } if len(schema.Properties) == 0 { schema.Properties = nil } - return schema + schema.Required = uniqueStrings(schema.Required) + return schema, nil +} + +// runPostHook calls the post-hook and handles ErrSkipProperty by restoring the snapshot and +// removing the property from schema. Returns a non-nil error only for non-ErrSkipProperty failures. +func (r *Reflector) runPostHook( + fn openapi.InterceptPropFunc, + schema *openapi.Schema, + prop *openapi.Schema, + name string, + field reflect.StructField, + parentType reflect.Type, +) error { + snap := snapshotParent(schema) + err := fn(openapi.InterceptPropParams{ + Name: name, + Field: field, + PropertySchema: prop, + ParentSchema: schema, + Processed: true, + ParentType: parentType, + }) + if err == nil { + return nil + } + if errors.Is(err, openapi.ErrSkipProperty) { + restoreParent(schema, snap) + delete(schema.Properties, name) + for i, req := range schema.Required { + if req == name { + schema.Required = append(schema.Required[:i], schema.Required[i+1:]...) + break + } + } + return nil + } + return err +} + +type parentSnapshot struct { + allOf []*openapi.Schema + anyOf []*openapi.Schema + oneOf []*openapi.Schema + extensions map[string]any + extra map[string]any +} + +func snapshotParent(s *openapi.Schema) parentSnapshot { + return parentSnapshot{ + allOf: s.AllOf, + anyOf: s.AnyOf, + oneOf: s.OneOf, + extensions: shallowCopyMap(s.Extensions), + extra: shallowCopyMap(s.Extra), + } +} + +func restoreParent(s *openapi.Schema, snap parentSnapshot) { + s.AllOf = snap.allOf + s.AnyOf = snap.anyOf + s.OneOf = snap.oneOf + s.Extensions = snap.extensions + s.Extra = snap.extra +} + +func shallowCopyMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func uniqueStrings(s []string) []string { + if len(s) == 0 { + return s + } + seen := make(map[string]struct{}, len(s)) + out := s[:0] + for _, v := range s { + if _, ok := seen[v]; !ok { + seen[v] = struct{}{} + out = append(out, v) + } + } + if len(out) == 0 { + return nil + } + return slices.Clip(out) } // SchemaExposer lets a value provide an OpenAPI schema for a specific version. diff --git a/internal/reflect/reflector_test.go b/internal/reflect/reflector_test.go index d54f4c2..3cd51e1 100644 --- a/internal/reflect/reflector_test.go +++ b/internal/reflect/reflector_test.go @@ -1,6 +1,7 @@ package reflect_test import ( + "errors" std_reflect "reflect" "testing" @@ -66,13 +67,15 @@ func TestReflector_SchemaForValue(t *testing.T) { t.Run("OneOf", func(t *testing.T) { val := spec.OneOf(1, "two") - schema := r.SchemaForValue(val, reflect.SchemaInline) + schema, err := r.SchemaForValue(val, reflect.SchemaInline) + require.NoError(t, err) assert.Len(t, schema.OneOf, 2) }) t.Run("SchemaPointer", func(t *testing.T) { expected := &openapi.Schema{Type: "boolean"} - schema := r.SchemaForValue(expected, reflect.SchemaInline) + schema, err := r.SchemaForValue(expected, reflect.SchemaInline) + require.NoError(t, err) assert.Equal(t, expected, schema) }) } @@ -172,7 +175,8 @@ func TestReflector_ParameterField_CustomMappingKeepsDefaultTag(t *testing.T) { ID int `path:"id" required:"true"` } - params, _ := r.RequestParts(Request{}, "") + params, _, err := r.RequestParts(Request{}, "") + require.NoError(t, err) require.Len(t, params, 1) assert.Equal(t, "id", params[0].Name) assert.Equal(t, "path", params[0].In) @@ -184,7 +188,8 @@ func TestReflector_RequestPartsAndStructSchemaBranches(t *testing.T) { r := reflect.NewReflector(cfg) t.Run("non-struct uses schema component", func(t *testing.T) { - params, body := r.RequestParts(123, "") + params, body, err := r.RequestParts(123, "") + require.NoError(t, err) assert.Nil(t, params) require.NotNil(t, body) assert.Equal(t, "integer", body.Type) @@ -194,7 +199,8 @@ func TestReflector_RequestPartsAndStructSchemaBranches(t *testing.T) { type Req struct { ID string `path:"id" required:"true"` } - params, body := r.RequestParts(Req{}, "") + params, body, err := r.RequestParts(Req{}, "") + require.NoError(t, err) require.Len(t, params, 1) assert.Equal(t, "id", params[0].Name) assert.Nil(t, body) @@ -205,7 +211,8 @@ func TestReflector_RequestPartsAndStructSchemaBranches(t *testing.T) { ID string `path:"id" required:"true"` Name string `json:"name"` } - params, body := r.RequestParts(Req{}, "application/json") + params, body, err := r.RequestParts(Req{}, "application/json") + require.NoError(t, err) require.Len(t, params, 1) require.NotNil(t, body) assert.Contains(t, body.Properties, "name") @@ -216,7 +223,8 @@ func TestReflector_RequestPartsAndStructSchemaBranches(t *testing.T) { ID string `path:"id" required:"true"` Email string `form:"email"` } - params, body := r.RequestParts(Req{}, "application/x-www-form-urlencoded") + params, body, err := r.RequestParts(Req{}, "application/x-www-form-urlencoded") + require.NoError(t, err) require.Len(t, params, 1) require.NotNil(t, body) assert.Contains(t, body.Properties, "email") @@ -231,7 +239,8 @@ func TestReflector_RequestPartsAndStructSchemaBranches(t *testing.T) { } cfg := option.WithOpenAPIConfig(option.WithReflectorConfig(option.TypeMapping(Src{}, Dst{}))) rr := reflect.NewReflector(cfg) - params, body := rr.RequestParts(Src{}, "") + params, body, err := rr.RequestParts(Src{}, "") + require.NoError(t, err) assert.Nil(t, params) require.NotNil(t, body) assert.Equal(t, "#/components/schemas/Dst", body.Ref) @@ -256,7 +265,8 @@ func TestStructSchema_InterceptProp(t *testing.T) { }, } r := reflect.NewReflector(cfg) - schema := r.SchemaForValue(Payload{}, reflect.SchemaInline) + schema, err := r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.NoError(t, err) assert.Contains(t, schema.Properties, "name") assert.NotContains(t, schema.Properties, "secret") }) @@ -273,7 +283,8 @@ func TestStructSchema_InterceptProp(t *testing.T) { }, } r := reflect.NewReflector(cfg) - schema := r.SchemaForValue(Payload{}, reflect.SchemaInline) + schema, err := r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.NoError(t, err) assert.Contains(t, schema.Properties, "name") assert.NotContains(t, schema.Properties, "secret") }) @@ -290,7 +301,8 @@ func TestStructSchema_InterceptProp(t *testing.T) { }, } r := reflect.NewReflector(cfg) - schema := r.SchemaForValue(Payload{}, reflect.SchemaInline) + schema, err := r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.NoError(t, err) require.Contains(t, schema.Properties, "name") assert.Equal(t, "intercepted", schema.Properties["name"].Description) assert.Empty(t, schema.Properties["secret"].Description) @@ -307,7 +319,7 @@ func TestStructSchema_InterceptProp(t *testing.T) { }, } r := reflect.NewReflector(cfg) - r.SchemaForValue(Payload{}, reflect.SchemaInline) + _, _ = r.SchemaForValue(Payload{}, reflect.SchemaInline) require.Len(t, calls, 4) // 2 fields × (pre + post) assert.False(t, calls[0]) assert.True(t, calls[1]) @@ -331,7 +343,8 @@ func TestStructSchema_InterceptProp(t *testing.T) { }, } r := reflect.NewReflector(cfg) - schema := r.SchemaForValue(WithRequired{}, reflect.SchemaInline) + schema, err := r.SchemaForValue(WithRequired{}, reflect.SchemaInline) + require.NoError(t, err) assert.Contains(t, schema.Required, "name") assert.NotContains(t, schema.Required, "secret") assert.NotContains(t, schema.Properties, "secret") @@ -349,7 +362,8 @@ func TestReflector_RequiredPropByValidateTag(t *testing.T) { r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( option.RequiredPropByValidateTag(), ))) - schema := r.SchemaForValue(Form{}, reflect.SchemaInline) + schema, err := r.SchemaForValue(Form{}, reflect.SchemaInline) + require.NoError(t, err) assert.Contains(t, schema.Required, "name") assert.NotContains(t, schema.Required, "email") assert.NotContains(t, schema.Required, "age") @@ -363,7 +377,8 @@ func TestReflector_RequiredPropByValidateTag(t *testing.T) { r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( option.RequiredPropByValidateTag("binding"), ))) - schema := r.SchemaForValue(BindingForm{}, reflect.SchemaInline) + schema, err := r.SchemaForValue(BindingForm{}, reflect.SchemaInline) + require.NoError(t, err) assert.Contains(t, schema.Required, "name") assert.NotContains(t, schema.Required, "email") }) @@ -376,7 +391,8 @@ func TestReflector_RequiredPropByValidateTag(t *testing.T) { r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( option.RequiredPropByValidateTag("validate", "|"), ))) - schema := r.SchemaForValue(PipeForm{}, reflect.SchemaInline) + schema, err := r.SchemaForValue(PipeForm{}, reflect.SchemaInline) + require.NoError(t, err) assert.Contains(t, schema.Required, "name") assert.NotContains(t, schema.Required, "email") }) @@ -388,11 +404,31 @@ func TestReflector_RequiredPropByValidateTag(t *testing.T) { r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( option.RequiredPropByValidateTag(), ))) - schema := r.SchemaForValue(Plain{}, reflect.SchemaInline) + schema, err := r.SchemaForValue(Plain{}, reflect.SchemaInline) + require.NoError(t, err) assert.Empty(t, schema.Required) }) + + t.Run("BothRequiredTagAndValidateTagNoDuplicate", func(t *testing.T) { + type Overlap struct { + Name string `json:"name" required:"true" validate:"required"` + } + r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( + option.RequiredPropByValidateTag(), + ))) + schema, err := r.SchemaForValue(Overlap{}, reflect.SchemaInline) + require.NoError(t, err) + count := 0 + for _, req := range schema.Required { + if req == "name" { + count++ + } + } + assert.Equal(t, 1, count, "name must appear exactly once in Required") + }) } +//nolint:gocognit // large integration test covering all InterceptSchema edge cases func TestStructSchema_InterceptSchema(t *testing.T) { t.Run("PreHookStopReturnCustomSchema", func(t *testing.T) { cfg := &openapi.Config{ @@ -408,7 +444,8 @@ func TestStructSchema_InterceptSchema(t *testing.T) { }, } r := reflect.NewReflector(cfg) - schema := r.SchemaForValue(0, reflect.SchemaInline) + schema, err := r.SchemaForValue(0, reflect.SchemaInline) + require.NoError(t, err) assert.Equal(t, "string", schema.Type) assert.Equal(t, "uuid", schema.Format) }) @@ -425,7 +462,8 @@ func TestStructSchema_InterceptSchema(t *testing.T) { }, } r := reflect.NewReflector(cfg) - schema := r.SchemaForValue("", reflect.SchemaInline) + schema, err := r.SchemaForValue("", reflect.SchemaInline) + require.NoError(t, err) assert.Equal(t, "intercepted", schema.Description) }) @@ -474,6 +512,139 @@ func TestStructSchema_InterceptSchema(t *testing.T) { assert.Equal(t, "custom", doc.Components.Schemas["Skipped"].Description) assert.Nil(t, doc.Components.Schemas["Skipped"].Properties) // StructSchema was skipped }) + + t.Run("PreHookErrorPropagated", func(t *testing.T) { + boom := errors.New("hook error") + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptSchema: func(params openapi.InterceptSchemaParams) (bool, error) { + if !params.Processed && params.Type == std_reflect.TypeFor[int]() { + return false, boom + } + return false, nil + }, + }, + } + r := reflect.NewReflector(cfg) + _, err := r.SchemaForValue(0, reflect.SchemaInline) + assert.ErrorIs(t, err, boom) + }) + + t.Run("PostHookErrorPropagated", func(t *testing.T) { + boom := errors.New("post hook error") + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptSchema: func(params openapi.InterceptSchemaParams) (bool, error) { + if params.Processed && params.Type == std_reflect.TypeFor[string]() { + return false, boom + } + return false, nil + }, + }, + } + r := reflect.NewReflector(cfg) + _, err := r.SchemaForValue("", reflect.SchemaInline) + assert.ErrorIs(t, err, boom) + }) + + t.Run("ChainingBothHooksFire", func(t *testing.T) { + var fired []string + r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( + option.InterceptSchema(func(params openapi.InterceptSchemaParams) (bool, error) { + if params.Processed { + fired = append(fired, "first") + } + return false, nil + }), + option.InterceptSchema(func(params openapi.InterceptSchemaParams) (bool, error) { + if params.Processed { + fired = append(fired, "second") + } + return false, nil + }), + ))) + _, err := r.SchemaForValue("", reflect.SchemaInline) + require.NoError(t, err) + assert.Equal(t, []string{"first", "second"}, fired) + }) + + t.Run("ChainingStopShortCircuits", func(t *testing.T) { + secondFired := false + r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( + option.InterceptSchema(func(_ openapi.InterceptSchemaParams) (bool, error) { + return true, nil // stop immediately + }), + option.InterceptSchema(func(_ openapi.InterceptSchemaParams) (bool, error) { + secondFired = true + return false, nil + }), + ))) + _, err := r.SchemaForValue(0, reflect.SchemaInline) + require.NoError(t, err) + assert.False(t, secondFired) + }) + + t.Run("StopAndErrorErrorWins", func(t *testing.T) { + boom := errors.New("stop and error") + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptSchema: func(_ openapi.InterceptSchemaParams) (bool, error) { + return true, boom // both stop and error + }, + }, + } + r := reflect.NewReflector(cfg) + _, err := r.SchemaForValue(0, reflect.SchemaInline) + assert.ErrorIs(t, err, boom) + }) + + t.Run("SchemaExposerPreHookStop", func(t *testing.T) { + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptSchema: func(params openapi.InterceptSchemaParams) (bool, error) { + if !params.Processed { + params.Schema.Type = "string" + params.Schema.Format = "override" + return true, nil + } + return false, nil + }, + }, + } + r := reflect.NewReflector(cfg) + // SchemaExposerType implements OpenAPISchema — without the fix only post-hook fired. + schema, err := r.SchemaForValue(SchemaExposerType{}, reflect.SchemaInline) + require.NoError(t, err) + assert.Equal(t, "string", schema.Type) + assert.Equal(t, "override", schema.Format) + }) + + t.Run("ComponentCleanedUpOnPreHookError", func(t *testing.T) { + type Target struct{ Name string } + boom := errors.New("pre-hook fail") + calls := 0 + cfg := &openapi.Config{ + OpenAPIVersion: openapi.Version312, + ReflectorConfig: &openapi.ReflectorConfig{}, + } + cfg.ReflectorConfig.InterceptSchema = func(params openapi.InterceptSchemaParams) (bool, error) { + if !params.Processed && params.Type == std_reflect.TypeFor[Target]() { + calls++ + if calls == 1 { + return false, boom + } + } + return false, nil + } + r := reflect.NewReflector(cfg) + _, err := r.SchemaForType(std_reflect.TypeFor[Target](), reflect.SchemaUseComponent, nil) + require.ErrorIs(t, err, boom) + // Second call must retry (not hit stale empty component from first call). + schema, err := r.SchemaForType(std_reflect.TypeFor[Target](), reflect.SchemaUseComponent, nil) + require.NoError(t, err) + assert.Equal(t, "#/components/schemas/Target", schema.Ref) + assert.Equal(t, 2, calls) + }) } // Ensure InterceptProp wires through spec.NewRouter to StructSchema. @@ -499,3 +670,154 @@ func TestReflector_InterceptPropViaRouter(t *testing.T) { assert.Contains(t, doc.Components.Schemas["Item"].Properties, "name") assert.NotContains(t, doc.Components.Schemas["Item"].Properties, "hidden") } + +func TestStructSchema_InterceptProp_NonSkipErrorPropagated(t *testing.T) { + type Payload struct { + Name string `json:"name"` + } + hookErr := errors.New("hook internal error") + + t.Run("PreHookErrorPropagated", func(t *testing.T) { + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptProp: func(params openapi.InterceptPropParams) error { + if !params.Processed { + return hookErr + } + return nil + }, + }, + } + r := reflect.NewReflector(cfg) + _, err := r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.Error(t, err) + assert.ErrorIs(t, err, hookErr) + }) + + t.Run("PostHookErrorPropagated", func(t *testing.T) { + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptProp: func(params openapi.InterceptPropParams) error { + if params.Processed { + return hookErr + } + return nil + }, + }, + } + r := reflect.NewReflector(cfg) + _, err := r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.Error(t, err) + assert.ErrorIs(t, err, hookErr) + }) +} + +func TestInterceptProp_Chaining(t *testing.T) { + type Payload struct { + Name string `json:"name"` + Email string `json:"email"` + Secret string `json:"secret"` + } + + var callLog []string + r := reflect.NewReflector(option.WithOpenAPIConfig(option.WithReflectorConfig( + option.InterceptProp(func(params openapi.InterceptPropParams) error { + if params.Processed { + callLog = append(callLog, "hook1:"+params.Name) + } + return nil + }), + option.InterceptProp(func(params openapi.InterceptPropParams) error { + if params.Processed && params.Name == "secret" { + return openapi.ErrSkipProperty + } + if params.Processed { + callLog = append(callLog, "hook2:"+params.Name) + } + return nil + }), + ))) + + schema, err := r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.NoError(t, err) + + // Both hooks fired for non-skipped fields. + assert.Contains(t, callLog, "hook1:name") + assert.Contains(t, callLog, "hook2:name") + assert.Contains(t, callLog, "hook1:email") + assert.Contains(t, callLog, "hook2:email") + + // Hook1 fired for secret (before hook2 returned ErrSkipProperty). + assert.Contains(t, callLog, "hook1:secret") + + // secret must be absent because hook2 returned ErrSkipProperty. + assert.NotContains(t, schema.Properties, "secret") + assert.Contains(t, schema.Properties, "name") + assert.Contains(t, schema.Properties, "email") +} + +func TestStructSchema_InterceptProp_PostHookSkipRestoresParentSnapshot(t *testing.T) { + type Payload struct { + Name string `json:"name"` + Secret string `json:"secret"` + } + + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptProp: func(params openapi.InterceptPropParams) error { + if params.Processed && params.Name == "secret" { + // Mutate parent before returning ErrSkipProperty. + params.ParentSchema.AllOf = append(params.ParentSchema.AllOf, &openapi.Schema{Type: "object"}) + params.ParentSchema.Extensions = map[string]any{"x-dirty": true} + return openapi.ErrSkipProperty + } + return nil + }, + }, + } + r := reflect.NewReflector(cfg) + schema, err := r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.NoError(t, err) + + // secret must be excluded. + assert.NotContains(t, schema.Properties, "secret") + + // Mutations made to ParentSchema before returning ErrSkipProperty must be rolled back. + assert.Empty(t, schema.AllOf, "AllOf must be restored after ErrSkipProperty") + assert.Nil(t, schema.Extensions, "Extensions must be restored after ErrSkipProperty") +} + +func TestStructSchema_InterceptProp_ParentType(t *testing.T) { + type Embedded struct { + EmbedField string `json:"embedField"` + } + type Payload struct { + Embedded + + Name string `json:"name"` + } + + var observedTypes []std_reflect.Type + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + InterceptProp: func(params openapi.InterceptPropParams) error { + if !params.Processed { + observedTypes = append(observedTypes, params.ParentType) + } + return nil + }, + }, + } + r := reflect.NewReflector(cfg) + _, err := r.SchemaForValue(Payload{}, reflect.SchemaInline) + require.NoError(t, err) + + // Should have observed two pre-hook calls (embedField + name). + require.Len(t, observedTypes, 2) + + // ParentType must always be the top-level struct type, not the embedded struct type. + expectedType := std_reflect.TypeFor[Payload]() + for _, pt := range observedTypes { + assert.Equal(t, expectedType, pt, "ParentType must always be the top-level struct type") + } +} diff --git a/openapi/config.go b/openapi/config.go index 571332a..44813d9 100644 --- a/openapi/config.go +++ b/openapi/config.go @@ -19,6 +19,7 @@ type InterceptPropParams struct { PropertySchema *Schema // nil when Processed=false ParentSchema *Schema Processed bool + ParentType reflect.Type // the struct type being reflected; same for all fields including embedded } // InterceptPropFunc intercepts field reflection to control or modify property schemas. diff --git a/option/reflector.go b/option/reflector.go index df34173..b1aa17c 100644 --- a/option/reflector.go +++ b/option/reflector.go @@ -35,13 +35,42 @@ func TypeMapping(src, dst any) ReflectorOption { } // InterceptSchema sets callback to intercept schema generation per type. +// If a previous hook exists, both are chained: the previous hook runs first. +// If the previous hook returns stop=true or an error, the next hook is not called. func InterceptSchema(fn openapi.InterceptSchemaFunc) ReflectorOption { - return func(cfg *openapi.ReflectorConfig) { cfg.InterceptSchema = fn } + return func(cfg *openapi.ReflectorConfig) { + if cfg.InterceptSchema == nil { + cfg.InterceptSchema = fn + return + } + prev := cfg.InterceptSchema + cfg.InterceptSchema = func(params openapi.InterceptSchemaParams) (bool, error) { + stop, err := prev(params) + if err != nil || stop { + return stop, err + } + return fn(params) + } + } } // InterceptProp sets callback to intercept property schema generation per field. +// If a previous hook exists, both are chained: the previous hook runs first, +// and the new hook runs only if the previous did not return an error. func InterceptProp(fn openapi.InterceptPropFunc) ReflectorOption { - return func(cfg *openapi.ReflectorConfig) { cfg.InterceptProp = fn } + return func(cfg *openapi.ReflectorConfig) { + if cfg.InterceptProp == nil { + cfg.InterceptProp = fn + return + } + prev := cfg.InterceptProp + cfg.InterceptProp = func(params openapi.InterceptPropParams) error { + if err := prev(params); err != nil { + return err + } + return fn(params) + } + } } // RequiredPropByValidateTag marks properties as required when their validate tag contains "required". From c2d86f9f3889ab1d090087966436451dc55e3c28 Mon Sep 17 00:00:00 2001 From: Ahmad Faiz Kamaludin Date: Mon, 11 May 2026 21:35:05 +0700 Subject: [PATCH 6/6] test: increase code coverage of this project --- internal/reflect/converter_test.go | 143 +++++++++++++++++++++++ internal/reflect/tags_test.go | 74 ++++++++++++ internal/reflect/utils_test.go | 48 ++++++++ internal/testutil/types_test.go | 38 +++++++ openapi/codec_test.go | 29 +++++ option/option_test.go | 176 +++++++++++++++++++++++++++++ router_internal_test.go | 24 ++++ 7 files changed, 532 insertions(+) create mode 100644 internal/testutil/types_test.go diff --git a/internal/reflect/converter_test.go b/internal/reflect/converter_test.go index 2d7c897..53e0f90 100644 --- a/internal/reflect/converter_test.go +++ b/internal/reflect/converter_test.go @@ -4,6 +4,7 @@ import ( std_reflect "reflect" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -133,3 +134,145 @@ func TestConverter_SchemaExposer(t *testing.T) { doc.Paths["/exposer"].Get.Responses["200"].Content["application/json"].Schema.Description, ) } + +func TestConverter_SchemaForType_Branches(t *testing.T) { + t.Run("primitive and collection kinds", func(t *testing.T) { + r := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version312}) + + cases := []struct { + name string + val any + typ std_reflect.Type + assert func(*testing.T, *openapi.Schema) + }{ + { + name: "bool", + val: true, + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, "boolean", s.Type) + }, + }, + { + name: "int32", + val: int32(1), + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, "integer", s.Type) + assert.Equal(t, "int32", s.Format) + }, + }, + { + name: "uint16", + val: uint16(1), + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, "integer", s.Type) + require.NotNil(t, s.Minimum) + assert.InDelta(t, 0.0, *s.Minimum, 0.0001) + }, + }, + { + name: "uint64", + val: uint64(1), + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, "integer", s.Type) + assert.Equal(t, "int64", s.Format) + }, + }, + { + name: "float64", + val: float64(1.25), + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, "number", s.Type) + assert.Equal(t, "double", s.Format) + }, + }, + { + name: "array", + val: [2]int{1, 2}, + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, "array", s.Type) + require.NotNil(t, s.Items) + assert.Equal(t, "integer", s.Items.Type) + }, + }, + { + name: "map", + val: map[string]bool{"ok": true}, + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, "object", s.Type) + require.NotNil(t, s.AdditionalProperties) + additional, ok := s.AdditionalProperties.(*openapi.Schema) + require.True(t, ok) + assert.Equal(t, "boolean", additional.Type) + }, + }, + { + name: "time", + val: time.Time{}, + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, "string", s.Type) + assert.Equal(t, "date-time", s.Format) + }, + }, + { + name: "interface", + typ: std_reflect.TypeFor[any](), + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, &openapi.Schema{}, s) + }, + }, + { + name: "default", + val: func() {}, + assert: func(t *testing.T, s *openapi.Schema) { + assert.Equal(t, &openapi.Schema{}, s) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + typ := tc.typ + if typ == nil { + typ = std_reflect.TypeOf(tc.val) + } + s, err := r.SchemaForType(typ, reflect.SchemaInline, nil) + require.NoError(t, err) + tc.assert(t, s) + }) + } + }) + + t.Run("byte slice encoding differs by version", func(t *testing.T) { + r304 := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version304}) + s304, err := r304.SchemaForType(std_reflect.TypeFor[[]byte](), reflect.SchemaInline, nil) + require.NoError(t, err) + assert.Equal(t, "string", s304.Type) + assert.Equal(t, "byte", s304.Format) + + r312 := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version312}) + s312, err := r312.SchemaForType(std_reflect.TypeFor[[]byte](), reflect.SchemaInline, nil) + require.NoError(t, err) + assert.Equal(t, "string", s312.Type) + assert.Equal(t, "base64", s312.ContentEncoding) + }) + + t.Run("nullable pointer to component schema", func(t *testing.T) { + type User struct { + ID string `json:"id"` + } + + r304 := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version304}) + s304, err := r304.SchemaForType(std_reflect.TypeFor[*User](), reflect.SchemaUseComponent, nil) + require.NoError(t, err) + assert.True(t, s304.Nullable) + require.Len(t, s304.AllOf, 1) + assert.Equal(t, "#/components/schemas/User", s304.AllOf[0].Ref) + + r312 := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version312}) + s312, err := r312.SchemaForType(std_reflect.TypeFor[*User](), reflect.SchemaUseComponent, nil) + require.NoError(t, err) + require.Len(t, s312.AnyOf, 2) + assert.Equal(t, "#/components/schemas/User", s312.AnyOf[0].Ref) + assert.Equal(t, "null", s312.AnyOf[1].Type) + }) +} diff --git a/internal/reflect/tags_test.go b/internal/reflect/tags_test.go index 3405a28..07cbc89 100644 --- a/internal/reflect/tags_test.go +++ b/internal/reflect/tags_test.go @@ -101,3 +101,77 @@ func TestApplyXMLTags(t *testing.T) { assert.Nil(t, s.XML) }) } + +func TestParseTagValueAndValues(t *testing.T) { + t.Run("ParseTagValue", func(t *testing.T) { + assert.Equal(t, true, reflect.ParseTagValue("true")) + assert.Equal(t, int64(42), reflect.ParseTagValue("42")) + assert.InDelta(t, 3.14, reflect.ParseTagValue("3.14"), 0.0001) + assert.Equal( + t, + map[string]any{"a": int64(1), "b": []any{int64(2), int64(3)}}, + reflect.ParseTagValue(`{"a":1,"b":[2,3]}`), + ) + assert.Equal(t, "plain-text", reflect.ParseTagValue("plain-text")) + }) + + t.Run("ParseTagValues", func(t *testing.T) { + assert.Equal(t, []any{float64(1), float64(2), float64(3)}, reflect.ParseTagValues("[1,2,3]")) + assert.Equal(t, []any{int64(1), int64(2), "three"}, reflect.ParseTagValues("1, 2, three")) + }) +} + +func TestApplyExclusiveLimit(t *testing.T) { + t.Run("OpenAPI30UsesBooleans", func(t *testing.T) { + r := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version304}) + s := &openapi.Schema{} + tag := std_reflect.StructTag(`exclusiveMaximum:"true" exclusiveMinimum:"false"`) + + r.ApplyExclusiveLimit(s, tag, "exclusiveMaximum") + r.ApplyExclusiveLimit(s, tag, "exclusiveMinimum") + + assert.Equal(t, true, s.ExclusiveMaximum) + assert.Equal(t, false, s.ExclusiveMinimum) + }) + + t.Run("OpenAPI31UsesNumbers", func(t *testing.T) { + r := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version312}) + s := &openapi.Schema{} + tag := std_reflect.StructTag(`exclusiveMaximum:"9.5" exclusiveMinimum:"-1.25"`) + + r.ApplyExclusiveLimit(s, tag, "exclusiveMaximum") + r.ApplyExclusiveLimit(s, tag, "exclusiveMinimum") + + assert.InDelta(t, 9.5, s.ExclusiveMaximum, 0.0001) + assert.InDelta(t, -1.25, s.ExclusiveMinimum, 0.0001) + }) +} + +func TestApplySchemaTags(t *testing.T) { + type Payload struct { + Value string `json:"value" type:"string,null" title:"User value" description:"desc" format:"uuid" pattern:"^u_" default:"42" example:"7" examples:"[1,2]" enum:"a,b" const:"fixed" multipleOf:"2" maximum:"10" minimum:"1" exclusiveMaximum:"9.5" exclusiveMinimum:"0.5" maxLength:"64" minLength:"1" maxItems:"3" minItems:"1" maxProperties:"5" minProperties:"1" uniqueItems:"true" nullable:"true" deprecated:"true" readOnly:"true" writeOnly:"true" contentEncoding:"gzip" contentMediaType:"application/json"` + } + + field, ok := std_reflect.TypeFor[Payload]().FieldByName("Value") + require.True(t, ok) + + r := reflect.NewReflector(&openapi.Config{OpenAPIVersion: openapi.Version312}) + schema := &openapi.Schema{} + r.ApplySchemaTags(schema, field) + + assert.Equal(t, []string{"string", "null"}, schema.Type) + assert.Equal(t, "User value", schema.Title) + assert.Equal(t, "desc", schema.Description) + assert.Equal(t, "uuid", schema.Format) + assert.Equal(t, "^u_", schema.Pattern) + assert.Equal(t, int64(42), schema.Default) + assert.Equal(t, int64(7), schema.Example) + assert.Equal(t, []any{float64(1), float64(2)}, schema.Examples) + assert.Equal(t, []any{"a", "b"}, schema.Enum) + assert.Equal(t, "fixed", schema.Const) + assert.True(t, schema.Deprecated) + assert.True(t, schema.ReadOnly) + assert.True(t, schema.WriteOnly) + assert.Equal(t, "gzip", schema.ContentEncoding) + assert.Equal(t, "application/json", schema.ContentMediaType) +} diff --git a/internal/reflect/utils_test.go b/internal/reflect/utils_test.go index c4472c6..96e7747 100644 --- a/internal/reflect/utils_test.go +++ b/internal/reflect/utils_test.go @@ -3,8 +3,11 @@ package reflect import ( "reflect" "testing" + "time" "github.com/stretchr/testify/assert" + + "github.com/oaswrap/spec/openapi" ) func TestSanitizeTypeName(t *testing.T) { @@ -65,3 +68,48 @@ func TestForEachField(t *testing.T) { assert.Contains(t, fields, "InnerField") assert.NotContains(t, fields, "Inner") // Inner is inlined } + +func TestInternalHelpers(t *testing.T) { + t.Run("sanitizeDefName", func(t *testing.T) { + assert.Equal(t, "Model", sanitizeDefName(nil, "Model", "github.com/oaswrap/spec")) + assert.Equal(t, "Model", sanitizeDefName(reflect.TypeFor[struct{}](), "Model", "github.com/oaswrap/spec")) + assert.Equal(t, "Model", sanitizeDefName(reflect.TypeFor[time.Time](), "Model", "")) + assert.Equal(t, "TimeModel", sanitizeDefName(reflect.TypeFor[time.Time](), "Model", "github.com/oaswrap/spec")) + }) + + t.Run("reflector helper accessors", func(t *testing.T) { + r := &Reflector{} + assert.Empty(t, r.callerPkgPath()) + assert.Nil(t, r.interceptPropFn()) + assert.Nil(t, r.interceptSchemaFn()) + + cfg := &openapi.Config{ + ReflectorConfig: &openapi.ReflectorConfig{ + DefNameCallerPkg: "github.com/oaswrap/spec", + InterceptProp: func(openapi.InterceptPropParams) error { return nil }, + InterceptSchema: func(openapi.InterceptSchemaParams) (bool, error) { return false, nil }, + }, + } + r = &Reflector{Config: cfg} + assert.Equal(t, "github.com/oaswrap/spec", r.callerPkgPath()) + assert.NotNil(t, r.interceptPropFn()) + assert.NotNil(t, r.interceptSchemaFn()) + }) + + t.Run("shallowCopyMap", func(t *testing.T) { + assert.Nil(t, shallowCopyMap(nil)) + + in := map[string]any{"a": 1} + out := shallowCopyMap(in) + assert.Equal(t, map[string]any{"a": 1}, out) + + in["a"] = 2 + assert.Equal(t, 1, out["a"]) + }) + + t.Run("uniqueStrings", func(t *testing.T) { + assert.Nil(t, uniqueStrings(nil)) + assert.Equal(t, []string{}, uniqueStrings([]string{})) + assert.Equal(t, []string{"a", "b", "c"}, uniqueStrings([]string{"a", "b", "a", "c", "b"})) + }) +} diff --git a/internal/testutil/types_test.go b/internal/testutil/types_test.go new file mode 100644 index 0000000..1629860 --- /dev/null +++ b/internal/testutil/types_test.go @@ -0,0 +1,38 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAssertGolden(t *testing.T) { + originalUpdate := *Update + defer func() { *Update = originalUpdate }() + + t.Run("compares against existing golden file", func(t *testing.T) { + *Update = false + dir := t.TempDir() + golden := filepath.Join(dir, "schema.yaml") + err := os.WriteFile(golden, []byte("openapi: 3.1.2\n"), 0o600) + require.NoError(t, err) + + AssertGolden(t, []byte("openapi: 3.1.2\n"), golden) + }) + + t.Run("creates golden file when update is enabled", func(t *testing.T) { + *Update = true + dir := t.TempDir() + golden := filepath.Join(dir, "nested", "schema.yaml") + want := []byte("openapi: 3.1.2\ninfo:\n title: Test\n") + + AssertGolden(t, want, golden) + + got, err := os.ReadFile(golden) + require.NoError(t, err) + assert.Equal(t, string(want), string(got)) + }) +} diff --git a/openapi/codec_test.go b/openapi/codec_test.go index 48eb5ce..1dde681 100644 --- a/openapi/codec_test.go +++ b/openapi/codec_test.go @@ -203,3 +203,32 @@ func TestToSerializable(t *testing.T) { } }) } + +func TestCodecInternalHelpers(t *testing.T) { + t.Run("mapToObject returns nil for nil map", func(t *testing.T) { + var m map[string]int + assert.Nil(t, mapToObject(reflect.ValueOf(m), objectJSON)) + }) + + t.Run("mapValueToSerializable handles nil interface", func(t *testing.T) { + holder := struct{ V any }{} + assert.Nil(t, mapValueToSerializable(reflect.ValueOf(holder).Field(0), objectJSON)) + }) + + t.Run("mapValueToSerializable converts nil slice to empty array", func(t *testing.T) { + var vals []string + assert.Equal(t, []any{}, mapValueToSerializable(reflect.ValueOf(vals), objectJSON)) + }) + + t.Run("sliceToSlice returns nil for nil slice", func(t *testing.T) { + var vals []string + assert.Nil(t, sliceToSlice(reflect.ValueOf(vals), objectJSON)) + }) + + t.Run("appendOrderedFields replaces existing keys", func(t *testing.T) { + fields := []orderedField{{Key: "x", Value: "old"}} + out := appendOrderedFields(fields, orderedField{Key: "x", Value: "new"}, orderedField{Key: "y", Value: 2}) + require.Len(t, out, 2) + assert.Equal(t, []orderedField{{Key: "x", Value: "new"}, {Key: "y", Value: 2}}, out) + }) +} diff --git a/option/option_test.go b/option/option_test.go index be8dce2..83cf53a 100644 --- a/option/option_test.go +++ b/option/option_test.go @@ -1,10 +1,12 @@ package option import ( + "errors" "reflect" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/oaswrap/spec-ui/config" "github.com/oaswrap/spec/openapi" @@ -237,6 +239,180 @@ func TestReflectorOptions(t *testing.T) { RequiredPropByValidateTag()(c) assert.NotNil(t, c.InterceptProp) }) + + t.Run("InterceptSchemaChainsAndShortCircuits", func(t *testing.T) { + t.Run("calls next when previous continues", func(t *testing.T) { + c := &openapi.ReflectorConfig{} + calls := []string{} + InterceptSchema(func(_ openapi.InterceptSchemaParams) (bool, error) { + calls = append(calls, "first") + return false, nil + })(c) + InterceptSchema(func(_ openapi.InterceptSchemaParams) (bool, error) { + calls = append(calls, "second") + return true, nil + })(c) + + stop, err := c.InterceptSchema(openapi.InterceptSchemaParams{}) + require.NoError(t, err) + assert.True(t, stop) + assert.Equal(t, []string{"first", "second"}, calls) + }) + + t.Run("skips next when previous stops", func(t *testing.T) { + c := &openapi.ReflectorConfig{} + calls := []string{} + InterceptSchema(func(_ openapi.InterceptSchemaParams) (bool, error) { + calls = append(calls, "first") + return true, nil + })(c) + InterceptSchema(func(_ openapi.InterceptSchemaParams) (bool, error) { + calls = append(calls, "second") + return false, nil + })(c) + + stop, err := c.InterceptSchema(openapi.InterceptSchemaParams{}) + require.NoError(t, err) + assert.True(t, stop) + assert.Equal(t, []string{"first"}, calls) + }) + + t.Run("skips next when previous errors", func(t *testing.T) { + boom := errors.New("boom") + c := &openapi.ReflectorConfig{} + calls := []string{} + InterceptSchema(func(_ openapi.InterceptSchemaParams) (bool, error) { + calls = append(calls, "first") + return false, boom + })(c) + InterceptSchema(func(_ openapi.InterceptSchemaParams) (bool, error) { + calls = append(calls, "second") + return false, nil + })(c) + + stop, err := c.InterceptSchema(openapi.InterceptSchemaParams{}) + assert.False(t, stop) + require.ErrorIs(t, err, boom) + assert.Equal(t, []string{"first"}, calls) + }) + }) + + t.Run("InterceptPropChainsAndShortCircuits", func(t *testing.T) { + t.Run("calls next when previous succeeds", func(t *testing.T) { + c := &openapi.ReflectorConfig{} + calls := []string{} + InterceptProp(func(_ openapi.InterceptPropParams) error { + calls = append(calls, "first") + return nil + })(c) + InterceptProp(func(_ openapi.InterceptPropParams) error { + calls = append(calls, "second") + return nil + })(c) + + err := c.InterceptProp(openapi.InterceptPropParams{}) + require.NoError(t, err) + assert.Equal(t, []string{"first", "second"}, calls) + }) + + t.Run("skips next when previous errors", func(t *testing.T) { + boom := errors.New("boom") + c := &openapi.ReflectorConfig{} + calls := []string{} + InterceptProp(func(_ openapi.InterceptPropParams) error { + calls = append(calls, "first") + return boom + })(c) + InterceptProp(func(_ openapi.InterceptPropParams) error { + calls = append(calls, "second") + return nil + })(c) + + err := c.InterceptProp(openapi.InterceptPropParams{}) + require.ErrorIs(t, err, boom) + assert.Equal(t, []string{"first"}, calls) + }) + }) + + t.Run("RequiredPropByValidateTag", func(t *testing.T) { + type reqDefault struct { + ID string `validate:"required,min=3"` + } + type reqCustom struct { + ID string `rules:"min=3|required"` + } + type notRequired struct { + Name string `validate:"min=1"` + } + + t.Run("adds required on processed field with default tag", func(t *testing.T) { + field, ok := reflect.TypeFor[reqDefault]().FieldByName("ID") + assert.True(t, ok) + parent := &openapi.Schema{} + c := &openapi.ReflectorConfig{} + RequiredPropByValidateTag()(c) + + err := c.InterceptProp(openapi.InterceptPropParams{ + Name: "id", + Field: field, + ParentSchema: parent, + Processed: true, + }) + require.NoError(t, err) + assert.Equal(t, []string{"id"}, parent.Required) + }) + + t.Run("does not add required before processed", func(t *testing.T) { + field, ok := reflect.TypeFor[reqDefault]().FieldByName("ID") + assert.True(t, ok) + parent := &openapi.Schema{} + c := &openapi.ReflectorConfig{} + RequiredPropByValidateTag()(c) + + err := c.InterceptProp(openapi.InterceptPropParams{ + Name: "id", + Field: field, + ParentSchema: parent, + Processed: false, + }) + require.NoError(t, err) + assert.Empty(t, parent.Required) + }) + + t.Run("supports custom tag and separator", func(t *testing.T) { + field, ok := reflect.TypeFor[reqCustom]().FieldByName("ID") + assert.True(t, ok) + parent := &openapi.Schema{} + c := &openapi.ReflectorConfig{} + RequiredPropByValidateTag("rules", "|")(c) + + err := c.InterceptProp(openapi.InterceptPropParams{ + Name: "id", + Field: field, + ParentSchema: parent, + Processed: true, + }) + require.NoError(t, err) + assert.Equal(t, []string{"id"}, parent.Required) + }) + + t.Run("ignores fields without required marker", func(t *testing.T) { + field, ok := reflect.TypeFor[notRequired]().FieldByName("Name") + assert.True(t, ok) + parent := &openapi.Schema{} + c := &openapi.ReflectorConfig{} + RequiredPropByValidateTag()(c) + + err := c.InterceptProp(openapi.InterceptPropParams{ + Name: "name", + Field: field, + ParentSchema: parent, + Processed: true, + }) + require.NoError(t, err) + assert.Empty(t, parent.Required) + }) + }) } func TestSecurityOptions(t *testing.T) { diff --git a/router_internal_test.go b/router_internal_test.go index 2ebf062..618a71e 100644 --- a/router_internal_test.go +++ b/router_internal_test.go @@ -66,3 +66,27 @@ func TestRoutePathRespectsPrefixForNonWebhook(t *testing.T) { webhook.Path("user.created") assert.Equal(t, "user.created", webhook.path) } + +func TestPackagePathFromFunc(t *testing.T) { + tests := []struct { + name string + funcName string + want string + }{ + {name: "empty", funcName: "", want: ""}, + {name: "no dot", funcName: "github.com/oaswrap/spec", want: ""}, + {name: "full package function", funcName: "github.com/oaswrap/spec.NewRouter", want: "github.com/oaswrap/spec"}, + { + name: "method receiver", + funcName: "github.com/oaswrap/spec.(*generator).Add", + want: "github.com/oaswrap/spec", + }, + {name: "simple package function", funcName: "main.main", want: "main"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, packagePathFromFunc(tt.funcName)) + }) + } +}