From 94aa22a1da5e7f0d4022144b6c403103b8876f09 Mon Sep 17 00:00:00 2001 From: Morgan Slater Date: Fri, 10 Jul 2026 11:19:31 +0100 Subject: [PATCH 1/5] feat: generate handler from provided open API 3 specification --- Makefile | 4 + go.mod | 36 ++ go.sum | 196 ++++++++ .../api/v1/Ticket_Allocation.swagger.yaml | 0 internal/api/v1/api.gen.go | 472 ++++++++++++++++++ internal/api/v1/doc.go | 3 + 6 files changed, 711 insertions(+) create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum rename Ticket_Allocation.swagger.yaml => internal/api/v1/Ticket_Allocation.swagger.yaml (100%) create mode 100644 internal/api/v1/api.gen.go create mode 100644 internal/api/v1/doc.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..de47dcb --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: generate + +generate: + go generate ./internal/api/v1/... diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d489457 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module ticket-allocation + +go 1.24.3 + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen + +require ( + github.com/gorilla/mux v1.8.0 + github.com/oapi-codegen/runtime v1.4.2 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/getkin/kin-openapi v0.135.0 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.7.2 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.9 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.19.2 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4291522 --- /dev/null +++ b/go.sum @@ -0,0 +1,196 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +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/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg= +github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs= +github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.2 h1:EKgVTwbZKQHZh8+ZnU+5TLVv1kedWZ2h0SRuFydoGio= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.2/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= +github.com/oapi-codegen/runtime v1.4.2 h1:GMxFVYLzoYLua+/KvzgSphkyK1lLTReQI9Vf4hvATKE= +github.com/oapi-codegen/runtime v1.4.2/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g= +github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= +github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/Ticket_Allocation.swagger.yaml b/internal/api/v1/Ticket_Allocation.swagger.yaml similarity index 100% rename from Ticket_Allocation.swagger.yaml rename to internal/api/v1/Ticket_Allocation.swagger.yaml diff --git a/internal/api/v1/api.gen.go b/internal/api/v1/api.gen.go new file mode 100644 index 0000000..43df9fc --- /dev/null +++ b/internal/api/v1/api.gen.go @@ -0,0 +1,472 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.2 DO NOT EDIT. +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/gorilla/mux" + "github.com/oapi-codegen/runtime" +) + +// CreatePurchaseApplicationVndAPIPlusJSONBody defines parameters for CreatePurchase. +type CreatePurchaseApplicationVndAPIPlusJSONBody struct { + Data *struct { + Attributes *struct { + Quantity *int `json:"quantity,omitempty"` + } `json:"attributes,omitempty"` + Relationships *struct { + TicketOption *struct { + Data *struct { + Id *string `json:"id,omitempty"` + Type *string `json:"type,omitempty"` + } `json:"data,omitempty"` + } `json:"ticket_option,omitempty"` + User *struct { + Data *struct { + Id *string `json:"id,omitempty"` + Type *string `json:"type,omitempty"` + } `json:"data,omitempty"` + } `json:"user,omitempty"` + } `json:"relationships,omitempty"` + Type *string `json:"type,omitempty"` + } `json:"data,omitempty"` +} + +// CreateTicketOptionApplicationVndAPIPlusJSONBody defines parameters for CreateTicketOption. +type CreateTicketOptionApplicationVndAPIPlusJSONBody struct { + Data *struct { + Attributes *struct { + Allocation *int `json:"allocation,omitempty"` + Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + } `json:"attributes,omitempty"` + Type *string `json:"type,omitempty"` + } `json:"data,omitempty"` +} + +// CreatePurchaseApplicationVndAPIPlusJSONRequestBody defines body for CreatePurchase for application/vnd.api+json ContentType. +type CreatePurchaseApplicationVndAPIPlusJSONRequestBody CreatePurchaseApplicationVndAPIPlusJSONBody + +// CreateTicketOptionApplicationVndAPIPlusJSONRequestBody defines body for CreateTicketOption for application/vnd.api+json ContentType. +type CreateTicketOptionApplicationVndAPIPlusJSONRequestBody CreateTicketOptionApplicationVndAPIPlusJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Create Purchase + // (POST /purchases) + CreatePurchase(w http.ResponseWriter, r *http.Request) + // Create ticket option + // (POST /ticket_options) + CreateTicketOption(w http.ResponseWriter, r *http.Request) + // Get ticket option + // (GET /ticket_options/{ticketOptionID}) + GetTicketOption(w http.ResponseWriter, r *http.Request, ticketOptionID string) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// CreatePurchase operation middleware +func (siw *ServerInterfaceWrapper) CreatePurchase(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreatePurchase(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// CreateTicketOption operation middleware +func (siw *ServerInterfaceWrapper) CreateTicketOption(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.CreateTicketOption(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetTicketOption operation middleware +func (siw *ServerInterfaceWrapper) GetTicketOption(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "ticketOptionID" ------------- + var ticketOptionID string + + err = runtime.BindStyledParameterWithOptions("simple", "ticketOptionID", mux.Vars(r)["ticketOptionID"], &ticketOptionID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ticketOptionID", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetTicketOption(w, r, ticketOptionID) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, GorillaServerOptions{}) +} + +type GorillaServerOptions struct { + BaseURL string + BaseRouter *mux.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r *mux.Router) http.Handler { + return HandlerWithOptions(si, GorillaServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r *mux.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, GorillaServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options GorillaServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = mux.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.HandleFunc(options.BaseURL+"/purchases", wrapper.CreatePurchase).Methods(http.MethodPost) + + r.HandleFunc(options.BaseURL+"/ticket_options", wrapper.CreateTicketOption).Methods(http.MethodPost) + + r.HandleFunc(options.BaseURL+"/ticket_options/{ticketOptionID}", wrapper.GetTicketOption).Methods(http.MethodGet) + + return r +} + +type CreatePurchaseRequestObject struct { + Body *CreatePurchaseApplicationVndAPIPlusJSONRequestBody +} + +type CreatePurchaseResponseObject interface { + VisitCreatePurchaseResponse(w http.ResponseWriter) error +} + +type CreatePurchase201Response struct { +} + +func (response CreatePurchase201Response) VisitCreatePurchaseResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type CreatePurchase400Response struct { +} + +func (response CreatePurchase400Response) VisitCreatePurchaseResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type CreatePurchase404Response struct { +} + +func (response CreatePurchase404Response) VisitCreatePurchaseResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +type CreateTicketOptionRequestObject struct { + Body *CreateTicketOptionApplicationVndAPIPlusJSONRequestBody +} + +type CreateTicketOptionResponseObject interface { + VisitCreateTicketOptionResponse(w http.ResponseWriter) error +} + +type CreateTicketOption201Response struct { +} + +func (response CreateTicketOption201Response) VisitCreateTicketOptionResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type CreateTicketOption400Response struct { +} + +func (response CreateTicketOption400Response) VisitCreateTicketOptionResponse(w http.ResponseWriter) error { + w.WriteHeader(400) + return nil +} + +type GetTicketOptionRequestObject struct { + TicketOptionID string `json:"ticketOptionID"` +} + +type GetTicketOptionResponseObject interface { + VisitGetTicketOptionResponse(w http.ResponseWriter) error +} + +type GetTicketOption200Response struct { +} + +func (response GetTicketOption200Response) VisitGetTicketOptionResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type GetTicketOption404Response struct { +} + +func (response GetTicketOption404Response) VisitGetTicketOptionResponse(w http.ResponseWriter) error { + w.WriteHeader(404) + return nil +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Create Purchase + // (POST /purchases) + CreatePurchase(ctx context.Context, request CreatePurchaseRequestObject) (CreatePurchaseResponseObject, error) + // Create ticket option + // (POST /ticket_options) + CreateTicketOption(ctx context.Context, request CreateTicketOptionRequestObject) (CreateTicketOptionResponseObject, error) + // Get ticket option + // (GET /ticket_options/{ticketOptionID}) + GetTicketOption(ctx context.Context, request GetTicketOptionRequestObject) (GetTicketOptionResponseObject, error) +} + +type StrictHandlerFunc func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) +type StrictMiddlewareFunc func(f StrictHandlerFunc, operationID string) StrictHandlerFunc + +type StrictHTTPServerOptions struct { + RequestErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) + ResponseErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + }} +} + +func NewStrictHandlerWithOptions(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc, options StrictHTTPServerOptions) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares, options: options} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc + options StrictHTTPServerOptions +} + +// CreatePurchase operation middleware +func (sh *strictHandler) CreatePurchase(w http.ResponseWriter, r *http.Request) { + var request CreatePurchaseRequestObject + + var body CreatePurchaseApplicationVndAPIPlusJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + if !errors.Is(err, io.EOF) { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + } else { + request.Body = &body + } + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.CreatePurchase(ctx, request.(CreatePurchaseRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreatePurchase") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(CreatePurchaseResponseObject); ok { + if err := validResponse.VisitCreatePurchaseResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// CreateTicketOption operation middleware +func (sh *strictHandler) CreateTicketOption(w http.ResponseWriter, r *http.Request) { + var request CreateTicketOptionRequestObject + + var body CreateTicketOptionApplicationVndAPIPlusJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + if !errors.Is(err, io.EOF) { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + } else { + request.Body = &body + } + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.CreateTicketOption(ctx, request.(CreateTicketOptionRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateTicketOption") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(CreateTicketOptionResponseObject); ok { + if err := validResponse.VisitCreateTicketOptionResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// GetTicketOption operation middleware +func (sh *strictHandler) GetTicketOption(w http.ResponseWriter, r *http.Request, ticketOptionID string) { + var request GetTicketOptionRequestObject + + request.TicketOptionID = ticketOptionID + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetTicketOption(ctx, request.(GetTicketOptionRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTicketOption") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetTicketOptionResponseObject); ok { + if err := validResponse.VisitGetTicketOptionResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} diff --git a/internal/api/v1/doc.go b/internal/api/v1/doc.go new file mode 100644 index 0000000..43e1dfc --- /dev/null +++ b/internal/api/v1/doc.go @@ -0,0 +1,3 @@ +package api + +//go:generate go tool oapi-codegen --generate "types,gorilla-server,strict-server" --package api -o api.gen.go Ticket_Allocation.swagger.yaml From aac862da8853e3560e99854f90ba39fd7681608a Mon Sep 17 00:00:00 2001 From: Morgan Slater Date: Fri, 10 Jul 2026 11:46:01 +0100 Subject: [PATCH 2/5] feat: add an integration test harness with real postgres DB --- Makefile | 8 +++++++- db/init/001_schema.sql | 28 ++++++++++++++++++++++++++++ docker-compose.yml | 18 ++++++++++++++++++ go.mod | 10 +++++++--- go.sum | 21 +++++++++++++++++++-- 5 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 db/init/001_schema.sql create mode 100644 docker-compose.yml diff --git a/Makefile b/Makefile index de47dcb..0df045b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,10 @@ -.PHONY: generate +.PHONY: generate local run test test-integration + +DATABASE_URL ?= postgres://ticket:ticket@localhost:5432/ticket_allocation?sslmode=disable +PORT ?= 3000 generate: go generate ./internal/api/v1/... + +test-integration: docker-up + TEST_DATABASE_URL="$(DATABASE_URL)" go test ./internal/store/postgres/... -tags=integration -race -count=1 -timeout=120s diff --git a/db/init/001_schema.sql b/db/init/001_schema.sql new file mode 100644 index 0000000..9a20e61 --- /dev/null +++ b/db/init/001_schema.sql @@ -0,0 +1,28 @@ +-- Ticket allocation schema with integrity constraints. +-- The purchased counter is denormalised for O(1) atomic reservation; +-- CHECK (purchased <= allocation) is the belt-and-braces backstop. + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + +CREATE TABLE public.ticket_options ( + id uuid DEFAULT public.uuid_generate_v4() PRIMARY KEY, + name character varying NOT NULL, + description character varying NOT NULL DEFAULT '', + allocation integer NOT NULL CHECK (allocation >= 0), + purchased integer NOT NULL DEFAULT 0, + created_at timestamp without time zone NOT NULL DEFAULT now(), + updated_at timestamp without time zone NOT NULL DEFAULT now(), + CONSTRAINT ticket_options_purchased_bounds + CHECK (purchased >= 0 AND purchased <= allocation) +); + +CREATE TABLE public.purchases ( + id uuid DEFAULT public.uuid_generate_v4() PRIMARY KEY, + quantity integer NOT NULL CHECK (quantity > 0), + user_id uuid NOT NULL, + ticket_option_id uuid NOT NULL REFERENCES public.ticket_options (id), + created_at timestamp without time zone NOT NULL DEFAULT now(), + updated_at timestamp without time zone NOT NULL DEFAULT now() +); + +CREATE INDEX purchases_ticket_option_id_idx ON public.purchases (ticket_option_id); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b1982c4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: ticket + POSTGRES_PASSWORD: ticket + POSTGRES_DB: ticket_allocation + ports: + - "5432:5432" + volumes: + - ./db/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ticket -d ticket_allocation"] + interval: 2s + timeout: 5s + retries: 15 + networks: + - ticket_net diff --git a/go.mod b/go.mod index d489457..d12d53c 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,13 @@ module ticket-allocation -go 1.24.3 +go 1.25.0 tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen require ( - github.com/gorilla/mux v1.8.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.10.0 + github.com/jmoiron/sqlx v1.4.0 github.com/oapi-codegen/runtime v1.4.2 ) @@ -15,7 +17,9 @@ require ( github.com/getkin/kin-openapi v0.135.0 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect diff --git a/go.sum b/go.sum index 4291522..94b0ce8 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= @@ -23,6 +25,8 @@ github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ98 github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= @@ -44,10 +48,18 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= @@ -58,8 +70,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -105,6 +121,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= From a4434b799e9f30fd1c1b92c100e2f3d943f4bd7b Mon Sep 17 00:00:00 2001 From: Morgan Slater Date: Sun, 12 Jul 2026 13:28:22 +0100 Subject: [PATCH 3/5] feat: implement ticketing and allocation endpoint Co-authored-by: Cursor Grok 4.5 --- .dockerignore | 12 + Dockerfile | 34 ++ Makefile | 11 +- cmd/server/main.go | 135 +++++ docker-compose.yml | 19 + .../api/v1/Ticket_Allocation.swagger.yaml | 320 +++++++--- internal/api/v1/api.gen.go | 314 ++++++---- internal/api/v1/doc.go | 2 +- internal/api/v1/errors.go | 85 +++ internal/api/v1/handler.go | 144 +++++ internal/api/v1/handler_test.go | 250 ++++++++ internal/store/memory/store.go | 103 ++++ internal/store/memory/store_test.go | 122 ++++ internal/store/postgres/store.go | 156 +++++ .../store/postgres/store_integration_test.go | 550 ++++++++++++++++++ internal/ticketing/ticketing.go | 109 ++++ internal/ticketing/ticketing_test.go | 86 +++ 17 files changed, 2268 insertions(+), 184 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 cmd/server/main.go create mode 100644 internal/api/v1/errors.go create mode 100644 internal/api/v1/handler.go create mode 100644 internal/api/v1/handler_test.go create mode 100644 internal/store/memory/store.go create mode 100644 internal/store/memory/store_test.go create mode 100644 internal/store/postgres/store.go create mode 100644 internal/store/postgres/store_integration_test.go create mode 100644 internal/ticketing/ticketing.go create mode 100644 internal/ticketing/ticketing_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c07978c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.dockerignore +.git +.idea +.vscode +*.md +*.test +bin/ +dist/ +vendor/ +.DS_Store +Ticket_Allocation.postman_collection.json +database_structure.sql diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..473d9a5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.25-alpine AS builder + +WORKDIR /src + +RUN apk add --no-cache git ca-certificates + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server + +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates wget \ + && adduser -D -H -u 10001 appuser + +WORKDIR /app + +COPY --from=builder /out/server /app/server + +USER appuser + +EXPOSE 3000 + +ENV PORT=3000 + +HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=10 \ + CMD wget -qO- http://127.0.0.1:3000/_health >/dev/null || exit 1 + +ENTRYPOINT ["/app/server"] diff --git a/Makefile b/Makefile index 0df045b..e2b20c2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: generate local run test test-integration +.PHONY: generate local run test test-integration docker-up docker-down DATABASE_URL ?= postgres://ticket:ticket@localhost:5432/ticket_allocation?sslmode=disable PORT ?= 3000 @@ -6,5 +6,14 @@ PORT ?= 3000 generate: go generate ./internal/api/v1/... +# Build and run the full stack (Postgres + API) on the docker network. +local: + docker compose up --build -d --wait + @echo "API listening on http://localhost:3000" + @echo "Health: curl -s http://localhost:3000/_health" + +test: + go test ./... -race -count=1 + test-integration: docker-up TEST_DATABASE_URL="$(DATABASE_URL)" go test ./internal/store/postgres/... -tags=integration -race -count=1 -timeout=120s diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..0d3e5b5 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + + api "ticket-allocation/internal/api/v1" + "ticket-allocation/internal/store/postgres" +) + +func main() { + if err := run(); err != nil { + log.Fatalf("server error: %v", err) + } +} + +func run() error { + cfg, err := loadConfig() + if err != nil { + return err + } + + db, err := sqlx.Connect("pgx", cfg.DatabaseURL) + if err != nil { + return fmt.Errorf("connect database: %w", err) + } + defer db.Close() + + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(25) + db.SetConnMaxLifetime(5 * time.Minute) + + store := postgres.NewStore(db) + handler := api.NewServer(store) + + mux := http.NewServeMux() + mux.HandleFunc("GET /_health", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + strict := api.NewStrictHandlerWithOptions(handler, nil, api.StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + writeJSONAPIError(w, http.StatusBadRequest, api.ErrorDocument{ + Errors: []api.ErrorObject{{ + Status: "400", + Code: strPtr("invalid_request_body"), + Title: "Invalid request body", + Detail: strPtr(err.Error()), + }}, + }) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + log.Printf("internal error: %v", err) + writeJSONAPIError(w, http.StatusInternalServerError, api.ErrorDocument{ + Errors: []api.ErrorObject{{ + Status: "500", + Code: strPtr("internal_error"), + Title: "Internal server error", + Detail: strPtr("an unexpected error occurred"), + }}, + }) + }, + }) + mux.Handle("/", api.Handler(strict)) + + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + log.Printf("listening on :%s", cfg.Port) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + close(errCh) + }() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown: %w", err) + } + return nil + case err := <-errCh: + return err + } +} + +type config struct { + DatabaseURL string + Port string +} + +func loadConfig() (config, error) { + cfg := config{ + DatabaseURL: os.Getenv("DATABASE_URL"), + Port: os.Getenv("PORT"), + } + if cfg.DatabaseURL == "" { + cfg.DatabaseURL = "postgres://ticket:ticket@localhost:5432/ticket_allocation?sslmode=disable" + } + if cfg.Port == "" { + cfg.Port = "3000" + } + return cfg, nil +} + +// would put this in a convert util package +func strPtr(s string) *string { return &s } + +func writeJSONAPIError(w http.ResponseWriter, status int, doc api.ErrorDocument) { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(doc) +} diff --git a/docker-compose.yml b/docker-compose.yml index b1982c4..01a813d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,3 +16,22 @@ services: retries: 15 networks: - ticket_net + + api: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + DATABASE_URL: postgres://ticket:ticket@postgres:5432/ticket_allocation?sslmode=disable + PORT: "3000" + depends_on: + postgres: + condition: service_healthy + networks: + - ticket_net + +networks: + ticket_net: + driver: bridge diff --git a/internal/api/v1/Ticket_Allocation.swagger.yaml b/internal/api/v1/Ticket_Allocation.swagger.yaml index ae52bd9..7b63b89 100644 --- a/internal/api/v1/Ticket_Allocation.swagger.yaml +++ b/internal/api/v1/Ticket_Allocation.swagger.yaml @@ -6,67 +6,65 @@ info: servers: - url: http://localhost:3000 paths: - /ticket_options/{ticketOptionID}: + /v1/ticket_options/{ticketOptionID}: get: summary: Get ticket option description: Get Ticket Option by provided Ticket Option ID operationId: getTicketOption + parameters: + - name: ticketOptionID + in: path + required: true + schema: + type: string + format: uuid responses: '200': description: Ticket option found + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TicketOptionDocument' '404': description: Ticket option not found - parameters: - - name: ticketOptionID - in: path - required: true - schema: - type: string - /ticket_options: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ErrorDocument' + /v1/ticket_options: post: summary: Create ticket option description: Create Ticket Option with given attributes operationId: createTicketOption requestBody: + required: true content: application/vnd.api+json: schema: - type: object - properties: - data: - type: object - properties: - attributes: - type: object - properties: - allocation: - type: integer - minimum: 0 - example: 100 - description: - type: string - example: ticket option description - name: - type: string - example: Test ticket option - type: - type: string - example: ticket_options + $ref: '#/components/schemas/CreateTicketOptionRequest' examples: Create ticket option: value: data: attributes: allocation: 100 - description: ticket opion description + description: ticket option description name: Test ticket option type: ticket_options responses: '201': description: Ticket option created + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/TicketOptionDocument' '400': description: Invalid request body - /purchases: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ErrorDocument' + /v1/purchases: post: summary: Create Purchase description: >- @@ -74,51 +72,11 @@ paths: relationship operationId: createPurchase requestBody: + required: true content: application/vnd.api+json: schema: - type: object - properties: - data: - type: object - properties: - attributes: - type: object - properties: - quantity: - type: integer - minimum: 1 - example: 1 - relationships: - type: object - properties: - ticket_option: - type: object - properties: - data: - type: object - properties: - id: - type: string - example: '{{ticketOptionID}}' - type: - type: string - example: ticket_options - user: - type: object - properties: - data: - type: object - properties: - id: - type: string - example: d6abe829-c28c-44ec-bee6-3183f2c53fef - type: - type: string - example: users - type: - type: string - example: purchases + $ref: '#/components/schemas/CreatePurchaseRequest' examples: Create Purchase: value: @@ -138,10 +96,224 @@ paths: responses: '201': description: Purchase created + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PurchaseDocument' '400': description: >- Invalid purchase (e.g. quantity exceeds remaining allocation or is not a positive integer); no tickets are persisted + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ErrorDocument' '404': description: Referenced ticket option not found -tags: [] + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ErrorDocument' +components: + schemas: + CreateTicketOptionRequest: + type: object + required: + - data + properties: + data: + type: object + required: + - type + - attributes + properties: + type: + type: string + example: ticket_options + attributes: + type: object + required: + - name + - allocation + properties: + name: + type: string + example: Test ticket option + description: + type: string + example: ticket option description + allocation: + type: integer + minimum: 0 + example: 100 + CreatePurchaseRequest: + type: object + required: + - data + properties: + data: + type: object + required: + - type + - attributes + - relationships + properties: + type: + type: string + example: purchases + attributes: + type: object + required: + - quantity + properties: + quantity: + type: integer + minimum: 1 + example: 1 + relationships: + type: object + required: + - ticket_option + - user + properties: + ticket_option: + $ref: '#/components/schemas/TicketOptionRelationship' + user: + $ref: '#/components/schemas/UserRelationship' + TicketOptionDocument: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/TicketOptionResource' + TicketOptionResource: + type: object + required: + - type + - id + - attributes + properties: + type: + type: string + example: ticket_options + id: + type: string + format: uuid + attributes: + type: object + required: + - name + - allocation + properties: + name: + type: string + description: + type: string + allocation: + type: integer + minimum: 0 + PurchaseDocument: + type: object + required: + - data + properties: + data: + $ref: '#/components/schemas/PurchaseResource' + PurchaseResource: + type: object + required: + - type + - id + - attributes + - relationships + properties: + type: + type: string + example: purchases + id: + type: string + format: uuid + attributes: + type: object + required: + - quantity + properties: + quantity: + type: integer + minimum: 1 + relationships: + type: object + required: + - ticket_option + - user + properties: + ticket_option: + $ref: '#/components/schemas/TicketOptionRelationship' + user: + $ref: '#/components/schemas/UserRelationship' + TicketOptionRelationship: + type: object + required: + - data + properties: + data: + type: object + required: + - type + - id + properties: + type: + type: string + example: ticket_options + id: + type: string + format: uuid + UserRelationship: + type: object + required: + - data + properties: + data: + type: object + required: + - type + - id + properties: + type: + type: string + example: users + id: + type: string + format: uuid + ErrorDocument: + type: object + required: + - errors + properties: + errors: + type: array + items: + $ref: '#/components/schemas/ErrorObject' + ErrorObject: + type: object + required: + - status + - title + properties: + status: + type: string + example: "400" + code: + type: string + example: invalid_purchase_quantity + title: + type: string + detail: + type: string + source: + type: object + properties: + pointer: + type: string + example: /data/attributes/quantity diff --git a/internal/api/v1/api.gen.go b/internal/api/v1/api.gen.go index 43df9fc..af19172 100644 --- a/internal/api/v1/api.gen.go +++ b/internal/api/v1/api.gen.go @@ -1,73 +1,130 @@ +//go:build go1.22 + // Package api provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.7.2 DO NOT EDIT. package api import ( + "bytes" "context" "encoding/json" - "errors" "fmt" - "io" "net/http" - "github.com/gorilla/mux" "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" ) -// CreatePurchaseApplicationVndAPIPlusJSONBody defines parameters for CreatePurchase. -type CreatePurchaseApplicationVndAPIPlusJSONBody struct { - Data *struct { - Attributes *struct { - Quantity *int `json:"quantity,omitempty"` - } `json:"attributes,omitempty"` - Relationships *struct { - TicketOption *struct { - Data *struct { - Id *string `json:"id,omitempty"` - Type *string `json:"type,omitempty"` - } `json:"data,omitempty"` - } `json:"ticket_option,omitempty"` - User *struct { - Data *struct { - Id *string `json:"id,omitempty"` - Type *string `json:"type,omitempty"` - } `json:"data,omitempty"` - } `json:"user,omitempty"` - } `json:"relationships,omitempty"` - Type *string `json:"type,omitempty"` - } `json:"data,omitempty"` -} - -// CreateTicketOptionApplicationVndAPIPlusJSONBody defines parameters for CreateTicketOption. -type CreateTicketOptionApplicationVndAPIPlusJSONBody struct { - Data *struct { - Attributes *struct { - Allocation *int `json:"allocation,omitempty"` +// CreatePurchaseRequest defines model for CreatePurchaseRequest. +type CreatePurchaseRequest struct { + Data struct { + Attributes struct { + Quantity int `json:"quantity"` + } `json:"attributes"` + Relationships struct { + TicketOption TicketOptionRelationship `json:"ticket_option"` + User UserRelationship `json:"user"` + } `json:"relationships"` + Type string `json:"type"` + } `json:"data"` +} + +// CreateTicketOptionRequest defines model for CreateTicketOptionRequest. +type CreateTicketOptionRequest struct { + Data struct { + Attributes struct { + Allocation int `json:"allocation"` Description *string `json:"description,omitempty"` - Name *string `json:"name,omitempty"` - } `json:"attributes,omitempty"` - Type *string `json:"type,omitempty"` - } `json:"data,omitempty"` + Name string `json:"name"` + } `json:"attributes"` + Type string `json:"type"` + } `json:"data"` +} + +// ErrorDocument defines model for ErrorDocument. +type ErrorDocument struct { + Errors []ErrorObject `json:"errors"` +} + +// ErrorObject defines model for ErrorObject. +type ErrorObject struct { + Code *string `json:"code,omitempty"` + Detail *string `json:"detail,omitempty"` + Source *struct { + Pointer *string `json:"pointer,omitempty"` + } `json:"source,omitempty"` + Status string `json:"status"` + Title string `json:"title"` +} + +// PurchaseDocument defines model for PurchaseDocument. +type PurchaseDocument struct { + Data PurchaseResource `json:"data"` +} + +// PurchaseResource defines model for PurchaseResource. +type PurchaseResource struct { + Attributes struct { + Quantity int `json:"quantity"` + } `json:"attributes"` + Id openapi_types.UUID `json:"id"` + Relationships struct { + TicketOption TicketOptionRelationship `json:"ticket_option"` + User UserRelationship `json:"user"` + } `json:"relationships"` + Type string `json:"type"` +} + +// TicketOptionDocument defines model for TicketOptionDocument. +type TicketOptionDocument struct { + Data TicketOptionResource `json:"data"` +} + +// TicketOptionRelationship defines model for TicketOptionRelationship. +type TicketOptionRelationship struct { + Data struct { + Id openapi_types.UUID `json:"id"` + Type string `json:"type"` + } `json:"data"` +} + +// TicketOptionResource defines model for TicketOptionResource. +type TicketOptionResource struct { + Attributes struct { + Allocation int `json:"allocation"` + Description *string `json:"description,omitempty"` + Name string `json:"name"` + } `json:"attributes"` + Id openapi_types.UUID `json:"id"` + Type string `json:"type"` +} + +// UserRelationship defines model for UserRelationship. +type UserRelationship struct { + Data struct { + Id openapi_types.UUID `json:"id"` + Type string `json:"type"` + } `json:"data"` } // CreatePurchaseApplicationVndAPIPlusJSONRequestBody defines body for CreatePurchase for application/vnd.api+json ContentType. -type CreatePurchaseApplicationVndAPIPlusJSONRequestBody CreatePurchaseApplicationVndAPIPlusJSONBody +type CreatePurchaseApplicationVndAPIPlusJSONRequestBody = CreatePurchaseRequest // CreateTicketOptionApplicationVndAPIPlusJSONRequestBody defines body for CreateTicketOption for application/vnd.api+json ContentType. -type CreateTicketOptionApplicationVndAPIPlusJSONRequestBody CreateTicketOptionApplicationVndAPIPlusJSONBody +type CreateTicketOptionApplicationVndAPIPlusJSONRequestBody = CreateTicketOptionRequest // ServerInterface represents all server handlers. type ServerInterface interface { // Create Purchase - // (POST /purchases) + // (POST /v1/purchases) CreatePurchase(w http.ResponseWriter, r *http.Request) // Create ticket option - // (POST /ticket_options) + // (POST /v1/ticket_options) CreateTicketOption(w http.ResponseWriter, r *http.Request) // Get ticket option - // (GET /ticket_options/{ticketOptionID}) - GetTicketOption(w http.ResponseWriter, r *http.Request, ticketOptionID string) + // (GET /v1/ticket_options/{ticketOptionID}) + GetTicketOption(w http.ResponseWriter, r *http.Request, ticketOptionID openapi_types.UUID) } // ServerInterfaceWrapper converts contexts to parameters. @@ -114,9 +171,9 @@ func (siw *ServerInterfaceWrapper) GetTicketOption(w http.ResponseWriter, r *htt _ = err // ------------- Path parameter "ticketOptionID" ------------- - var ticketOptionID string + var ticketOptionID openapi_types.UUID - err = runtime.BindStyledParameterWithOptions("simple", "ticketOptionID", mux.Vars(r)["ticketOptionID"], &ticketOptionID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + err = runtime.BindStyledParameterWithOptions("simple", "ticketOptionID", r.PathValue("ticketOptionID"), &ticketOptionID, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) if err != nil { siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "ticketOptionID", Err: err}) return @@ -204,55 +261,60 @@ func (e *TooManyValuesForParamError) Error() string { // Handler creates http.Handler with routing matching OpenAPI spec. func Handler(si ServerInterface) http.Handler { - return HandlerWithOptions(si, GorillaServerOptions{}) + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler } -type GorillaServerOptions struct { +type StdHTTPServerOptions struct { BaseURL string - BaseRouter *mux.Router + BaseRouter ServeMux Middlewares []MiddlewareFunc ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) } // HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. -func HandlerFromMux(si ServerInterface, r *mux.Router) http.Handler { - return HandlerWithOptions(si, GorillaServerOptions{ - BaseRouter: r, +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, }) } -func HandlerFromMuxWithBaseURL(si ServerInterface, r *mux.Router, baseURL string) http.Handler { - return HandlerWithOptions(si, GorillaServerOptions{ +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ BaseURL: baseURL, - BaseRouter: r, + BaseRouter: m, }) } // HandlerWithOptions creates http.Handler with additional options -func HandlerWithOptions(si ServerInterface, options GorillaServerOptions) http.Handler { - r := options.BaseRouter +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter - if r == nil { - r = mux.NewRouter() + if m == nil { + m = http.NewServeMux() } if options.ErrorHandlerFunc == nil { options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { http.Error(w, err.Error(), http.StatusBadRequest) } } + wrapper := ServerInterfaceWrapper{ Handler: si, HandlerMiddlewares: options.Middlewares, ErrorHandlerFunc: options.ErrorHandlerFunc, } - r.HandleFunc(options.BaseURL+"/purchases", wrapper.CreatePurchase).Methods(http.MethodPost) - - r.HandleFunc(options.BaseURL+"/ticket_options", wrapper.CreateTicketOption).Methods(http.MethodPost) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/v1/purchases", wrapper.CreatePurchase) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/v1/ticket_options", wrapper.CreateTicketOption) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/v1/ticket_options/{ticketOptionID}", wrapper.GetTicketOption) - r.HandleFunc(options.BaseURL+"/ticket_options/{ticketOptionID}", wrapper.GetTicketOption).Methods(http.MethodGet) - - return r + return m } type CreatePurchaseRequestObject struct { @@ -263,28 +325,46 @@ type CreatePurchaseResponseObject interface { VisitCreatePurchaseResponse(w http.ResponseWriter) error } -type CreatePurchase201Response struct { -} +type CreatePurchase201ApplicationVndAPIPlusJSONResponse PurchaseDocument + +func (response CreatePurchase201ApplicationVndAPIPlusJSONResponse) VisitCreatePurchaseResponse(w http.ResponseWriter) error { -func (response CreatePurchase201Response) VisitCreatePurchaseResponse(w http.ResponseWriter) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/vnd.api+json") w.WriteHeader(201) - return nil + _, err := buf.WriteTo(w) + return err } -type CreatePurchase400Response struct { -} +type CreatePurchase400ApplicationVndAPIPlusJSONResponse ErrorDocument + +func (response CreatePurchase400ApplicationVndAPIPlusJSONResponse) VisitCreatePurchaseResponse(w http.ResponseWriter) error { -func (response CreatePurchase400Response) VisitCreatePurchaseResponse(w http.ResponseWriter) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/vnd.api+json") w.WriteHeader(400) - return nil + _, err := buf.WriteTo(w) + return err } -type CreatePurchase404Response struct { -} +type CreatePurchase404ApplicationVndAPIPlusJSONResponse ErrorDocument + +func (response CreatePurchase404ApplicationVndAPIPlusJSONResponse) VisitCreatePurchaseResponse(w http.ResponseWriter) error { -func (response CreatePurchase404Response) VisitCreatePurchaseResponse(w http.ResponseWriter) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/vnd.api+json") w.WriteHeader(404) - return nil + _, err := buf.WriteTo(w) + return err } type CreateTicketOptionRequestObject struct { @@ -295,56 +375,80 @@ type CreateTicketOptionResponseObject interface { VisitCreateTicketOptionResponse(w http.ResponseWriter) error } -type CreateTicketOption201Response struct { -} +type CreateTicketOption201ApplicationVndAPIPlusJSONResponse TicketOptionDocument + +func (response CreateTicketOption201ApplicationVndAPIPlusJSONResponse) VisitCreateTicketOptionResponse(w http.ResponseWriter) error { -func (response CreateTicketOption201Response) VisitCreateTicketOptionResponse(w http.ResponseWriter) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/vnd.api+json") w.WriteHeader(201) - return nil + _, err := buf.WriteTo(w) + return err } -type CreateTicketOption400Response struct { -} +type CreateTicketOption400ApplicationVndAPIPlusJSONResponse ErrorDocument + +func (response CreateTicketOption400ApplicationVndAPIPlusJSONResponse) VisitCreateTicketOptionResponse(w http.ResponseWriter) error { -func (response CreateTicketOption400Response) VisitCreateTicketOptionResponse(w http.ResponseWriter) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/vnd.api+json") w.WriteHeader(400) - return nil + _, err := buf.WriteTo(w) + return err } type GetTicketOptionRequestObject struct { - TicketOptionID string `json:"ticketOptionID"` + TicketOptionID openapi_types.UUID `json:"ticketOptionID"` } type GetTicketOptionResponseObject interface { VisitGetTicketOptionResponse(w http.ResponseWriter) error } -type GetTicketOption200Response struct { -} +type GetTicketOption200ApplicationVndAPIPlusJSONResponse TicketOptionDocument + +func (response GetTicketOption200ApplicationVndAPIPlusJSONResponse) VisitGetTicketOptionResponse(w http.ResponseWriter) error { -func (response GetTicketOption200Response) VisitGetTicketOptionResponse(w http.ResponseWriter) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/vnd.api+json") w.WriteHeader(200) - return nil + _, err := buf.WriteTo(w) + return err } -type GetTicketOption404Response struct { -} +type GetTicketOption404ApplicationVndAPIPlusJSONResponse ErrorDocument + +func (response GetTicketOption404ApplicationVndAPIPlusJSONResponse) VisitGetTicketOptionResponse(w http.ResponseWriter) error { -func (response GetTicketOption404Response) VisitGetTicketOptionResponse(w http.ResponseWriter) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/vnd.api+json") w.WriteHeader(404) - return nil + _, err := buf.WriteTo(w) + return err } // StrictServerInterface represents all server handlers. type StrictServerInterface interface { // Create Purchase - // (POST /purchases) + // (POST /v1/purchases) CreatePurchase(ctx context.Context, request CreatePurchaseRequestObject) (CreatePurchaseResponseObject, error) // Create ticket option - // (POST /ticket_options) + // (POST /v1/ticket_options) CreateTicketOption(ctx context.Context, request CreateTicketOptionRequestObject) (CreateTicketOptionResponseObject, error) // Get ticket option - // (GET /ticket_options/{ticketOptionID}) + // (GET /v1/ticket_options/{ticketOptionID}) GetTicketOption(ctx context.Context, request GetTicketOptionRequestObject) (GetTicketOptionResponseObject, error) } @@ -383,13 +487,10 @@ func (sh *strictHandler) CreatePurchase(w http.ResponseWriter, r *http.Request) var body CreatePurchaseApplicationVndAPIPlusJSONRequestBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - if !errors.Is(err, io.EOF) { - sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) - return - } - } else { - request.Body = &body + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return } + request.Body = &body handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.CreatePurchase(ctx, request.(CreatePurchaseRequestObject)) @@ -417,13 +518,10 @@ func (sh *strictHandler) CreateTicketOption(w http.ResponseWriter, r *http.Reque var body CreateTicketOptionApplicationVndAPIPlusJSONRequestBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - if !errors.Is(err, io.EOF) { - sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) - return - } - } else { - request.Body = &body + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return } + request.Body = &body handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { return sh.ssi.CreateTicketOption(ctx, request.(CreateTicketOptionRequestObject)) @@ -446,7 +544,7 @@ func (sh *strictHandler) CreateTicketOption(w http.ResponseWriter, r *http.Reque } // GetTicketOption operation middleware -func (sh *strictHandler) GetTicketOption(w http.ResponseWriter, r *http.Request, ticketOptionID string) { +func (sh *strictHandler) GetTicketOption(w http.ResponseWriter, r *http.Request, ticketOptionID openapi_types.UUID) { var request GetTicketOptionRequestObject request.TicketOptionID = ticketOptionID diff --git a/internal/api/v1/doc.go b/internal/api/v1/doc.go index 43e1dfc..92ea0db 100644 --- a/internal/api/v1/doc.go +++ b/internal/api/v1/doc.go @@ -1,3 +1,3 @@ package api -//go:generate go tool oapi-codegen --generate "types,gorilla-server,strict-server" --package api -o api.gen.go Ticket_Allocation.swagger.yaml +//go:generate go tool oapi-codegen --generate "types,std-http-server,strict-server" --package api -o api.gen.go Ticket_Allocation.swagger.yaml diff --git a/internal/api/v1/errors.go b/internal/api/v1/errors.go new file mode 100644 index 0000000..bd4ca33 --- /dev/null +++ b/internal/api/v1/errors.go @@ -0,0 +1,85 @@ +package api + +import ( + "errors" + "net/http" + + "ticket-allocation/internal/ticketing" +) + +func ptr[T any](v T) *T { return &v } + +func newErrorDocument(status, code, title, detail, pointer string) ErrorDocument { + obj := ErrorObject{ + Status: status, + Title: title, + } + if code != "" { + obj.Code = ptr(code) + } + if detail != "" { + obj.Detail = ptr(detail) + } + if pointer != "" { + obj.Source = &struct { + Pointer *string `json:"pointer,omitempty"` + }{Pointer: ptr(pointer)} + } + return ErrorDocument{Errors: []ErrorObject{obj}} +} + +func invalidInputError(err *ticketing.InvalidInputError) ErrorDocument { + return newErrorDocument( + "400", + "invalid_input", + "Invalid request", + err.Detail, + err.Pointer, + ) +} + +func insufficientAllocationError() ErrorDocument { + return newErrorDocument( + "400", + "invalid_purchase_quantity", + "Unable to purchase provided quantity", + "Unable to reserve given quantity of ticket options", + "/data/attributes/quantity", + ) +} + +func notFoundError(detail string) ErrorDocument { + return newErrorDocument( + "404", + "not_found", + "Resource not found", + detail, + "", + ) +} + +func badRequestBodyError(detail string) ErrorDocument { + return newErrorDocument( + "400", + "invalid_request_body", + "Invalid request body", + detail, + "", + ) +} + +// MapDomainError maps domain errors to a JSON:API ErrorDocument and HTTP status. +// Returns ok=false when the error is not a known domain error. +func MapDomainError(err error) (doc ErrorDocument, status int, ok bool) { + var invalid *ticketing.InvalidInputError + switch { + case errors.As(err, &invalid): + return invalidInputError(invalid), http.StatusBadRequest, true + case errors.Is(err, ticketing.ErrInsufficientAllocation): + return insufficientAllocationError(), http.StatusBadRequest, true + case errors.Is(err, ticketing.ErrTicketOptionNotFound): + return notFoundError("ticket option not found"), http.StatusNotFound, true + default: + return ErrorDocument{}, 0, false + } +} diff --git a/internal/api/v1/handler.go b/internal/api/v1/handler.go new file mode 100644 index 0000000..5fff8ec --- /dev/null +++ b/internal/api/v1/handler.go @@ -0,0 +1,144 @@ +package api + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" + + "ticket-allocation/internal/ticketing" +) + +// Server implements StrictServerInterface. +type Server struct { + store ticketing.Store +} + +func NewServer(store ticketing.Store) *Server { + return &Server{store: store} +} + +func (h *Server) CreateTicketOption(ctx context.Context, request CreateTicketOptionRequestObject) (CreateTicketOptionResponseObject, error) { + if request.Body == nil { + return CreateTicketOption400ApplicationVndAPIPlusJSONResponse( + badRequestBodyError("request body is required"), + ), nil + } + + attrs := request.Body.Data.Attributes + description := "" + if attrs.Description != nil { + description = *attrs.Description + } + + params := ticketing.CreateTicketOptionParams{ + Name: attrs.Name, + Description: description, + Allocation: attrs.Allocation, + } + if err := params.Validate(); err != nil { + var invalid *ticketing.InvalidInputError + if errors.As(err, &invalid) { + return CreateTicketOption400ApplicationVndAPIPlusJSONResponse(invalidInputError(invalid)), nil + } + return CreateTicketOption400ApplicationVndAPIPlusJSONResponse( + badRequestBodyError(err.Error()), + ), nil + } + + option, err := h.store.CreateTicketOption(ctx, params) + if err != nil { + if doc, _, ok := MapDomainError(err); ok { + return CreateTicketOption400ApplicationVndAPIPlusJSONResponse(doc), nil + } + return nil, fmt.Errorf("create ticket option: %w", err) + } + + return CreateTicketOption201ApplicationVndAPIPlusJSONResponse(toTicketOptionDocument(option)), nil +} + +func (h *Server) GetTicketOption(ctx context.Context, request GetTicketOptionRequestObject) (GetTicketOptionResponseObject, error) { + option, err := h.store.GetTicketOption(ctx, uuid.UUID(request.TicketOptionID)) + if err != nil { + if errors.Is(err, ticketing.ErrTicketOptionNotFound) { + return GetTicketOption404ApplicationVndAPIPlusJSONResponse( + notFoundError("ticket option not found"), + ), nil + } + return nil, fmt.Errorf("get ticket option: %w", err) + } + + return GetTicketOption200ApplicationVndAPIPlusJSONResponse(toTicketOptionDocument(option)), nil +} + +func (h *Server) CreatePurchase(ctx context.Context, request CreatePurchaseRequestObject) (CreatePurchaseResponseObject, error) { + if request.Body == nil { + return CreatePurchase400ApplicationVndAPIPlusJSONResponse( + badRequestBodyError("request body is required"), + ), nil + } + + data := request.Body.Data + params := ticketing.CreatePurchaseParams{ + Quantity: data.Attributes.Quantity, + UserID: uuid.UUID(data.Relationships.User.Data.Id), + TicketOptionID: uuid.UUID(data.Relationships.TicketOption.Data.Id), + } + if err := params.Validate(); err != nil { + var invalid *ticketing.InvalidInputError + if errors.As(err, &invalid) { + return CreatePurchase400ApplicationVndAPIPlusJSONResponse(invalidInputError(invalid)), nil + } + return CreatePurchase400ApplicationVndAPIPlusJSONResponse( + badRequestBodyError(err.Error()), + ), nil + } + + purchase, err := h.store.CreatePurchase(ctx, params) + if err != nil { + switch { + case errors.Is(err, ticketing.ErrTicketOptionNotFound): + return CreatePurchase404ApplicationVndAPIPlusJSONResponse( + notFoundError("ticket option not found"), + ), nil + case errors.Is(err, ticketing.ErrInsufficientAllocation): + return CreatePurchase400ApplicationVndAPIPlusJSONResponse( + insufficientAllocationError(), + ), nil + default: + var invalid *ticketing.InvalidInputError + if errors.As(err, &invalid) { + return CreatePurchase400ApplicationVndAPIPlusJSONResponse(invalidInputError(invalid)), nil + } + return nil, fmt.Errorf("create purchase: %w", err) + } + } + + return CreatePurchase201ApplicationVndAPIPlusJSONResponse(toPurchaseDocument(purchase)), nil +} + +func toTicketOptionDocument(option ticketing.TicketOption) TicketOptionDocument { + doc := TicketOptionDocument{} + doc.Data.Type = "ticket_options" + doc.Data.Id = openapi_types.UUID(option.ID) + doc.Data.Attributes.Name = option.Name + doc.Data.Attributes.Allocation = option.Allocation + if option.Description != "" { + doc.Data.Attributes.Description = ptr(option.Description) + } + return doc +} + +func toPurchaseDocument(purchase ticketing.Purchase) PurchaseDocument { + doc := PurchaseDocument{} + doc.Data.Type = "purchases" + doc.Data.Id = openapi_types.UUID(purchase.ID) + doc.Data.Attributes.Quantity = purchase.Quantity + doc.Data.Relationships.TicketOption.Data.Type = "ticket_options" + doc.Data.Relationships.TicketOption.Data.Id = openapi_types.UUID(purchase.TicketOptionID) + doc.Data.Relationships.User.Data.Type = "users" + doc.Data.Relationships.User.Data.Id = openapi_types.UUID(purchase.UserID) + return doc +} diff --git a/internal/api/v1/handler_test.go b/internal/api/v1/handler_test.go new file mode 100644 index 0000000..076f023 --- /dev/null +++ b/internal/api/v1/handler_test.go @@ -0,0 +1,250 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + + api "ticket-allocation/internal/api/v1" + "ticket-allocation/internal/store/memory" +) + +func newTestServer(t *testing.T) http.Handler { + t.Helper() + store := memory.NewStore() + handler := api.NewServer(store) + return api.Handler(api.NewStrictHandlerWithOptions(handler, nil, api.StrictHTTPServerOptions{ + RequestErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + w.Header().Set("Content-Type", "application/vnd.api+json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(api.ErrorDocument{ + Errors: []api.ErrorObject{{ + Status: "400", + Title: "Invalid request body", + Detail: strPtr(err.Error()), + }}, + }) + }, + ResponseErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) { + t.Errorf("unexpected handler error: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + }, + })) +} + +func strPtr(s string) *string { return &s } + +func doJSON(t *testing.T, h http.Handler, method, path string, body any) (int, map[string]any) { + t.Helper() + + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal: %v", err) + } + rdr = bytes.NewReader(b) + } + + req := httptest.NewRequest(method, path, rdr) + if body != nil { + req.Header.Set("Content-Type", "application/vnd.api+json") + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + bodyBytes := rr.Body.Bytes() + if len(bodyBytes) == 0 { + return rr.Code, nil + } + + var payload map[string]any + if err := json.Unmarshal(bodyBytes, &payload); err != nil { + t.Fatalf("%s %s: response is not valid JSON (status=%d Content-Type=%q): %v\nbody=%q", + method, path, rr.Code, rr.Header().Get("Content-Type"), err, string(bodyBytes)) + } + return rr.Code, payload +} + +func createTicketOption(t *testing.T, h http.Handler, name string, allocation int) string { + t.Helper() + status, body := doJSON(t, h, http.MethodPost, "/v1/ticket_options", map[string]any{ + "data": map[string]any{ + "type": "ticket_options", + "attributes": map[string]any{ + "name": name, + "description": "desc", + "allocation": allocation, + }, + }, + }) + if status != http.StatusCreated { + t.Fatalf("create ticket option status = %d, body=%v", status, body) + } + data := body["data"].(map[string]any) + return data["id"].(string) +} + +func TestCreateAndGetTicketOption(t *testing.T) { + t.Parallel() + h := newTestServer(t) + + id := createTicketOption(t, h, "example", 100) + + status, body := doJSON(t, h, http.MethodGet, "/v1/ticket_options/"+id, nil) + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } + attrs := body["data"].(map[string]any)["attributes"].(map[string]any) + if attrs["name"] != "example" { + t.Fatalf("name = %v", attrs["name"]) + } + if int(attrs["allocation"].(float64)) != 100 { + t.Fatalf("allocation = %v", attrs["allocation"]) + } +} + +func TestGetTicketOptionNotFound(t *testing.T) { + t.Parallel() + h := newTestServer(t) + + status, body := doJSON(t, h, http.MethodGet, "/v1/ticket_options/"+uuid.NewString(), nil) + if status != http.StatusNotFound { + t.Fatalf("status = %d, want 404, body=%v", status, body) + } + errors := body["errors"].([]any) + if len(errors) == 0 { + t.Fatal("expected errors array") + } +} + +func TestCreateTicketOptionValidation(t *testing.T) { + t.Parallel() + h := newTestServer(t) + + status, body := doJSON(t, h, http.MethodPost, "/v1/ticket_options", map[string]any{ + "data": map[string]any{ + "type": "ticket_options", + "attributes": map[string]any{ + "name": "", + "allocation": 10, + }, + }, + }) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body=%v", status, body) + } +} + +func TestPurchaseSuccessAndOversell(t *testing.T) { + t.Parallel() + h := newTestServer(t) + + optionID := createTicketOption(t, h, "GA", 2) + userID := uuid.NewString() + + status, body := doJSON(t, h, http.MethodPost, "/v1/purchases", purchaseBody(optionID, userID, 2)) + if status != http.StatusCreated { + t.Fatalf("status = %d, want 201, body=%v", status, body) + } + + status, body = doJSON(t, h, http.MethodPost, "/v1/purchases", purchaseBody(optionID, userID, 1)) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body=%v", status, body) + } + errObj := body["errors"].([]any)[0].(map[string]any) + if errObj["code"] != "invalid_purchase_quantity" { + t.Fatalf("code = %v", errObj["code"]) + } + source := errObj["source"].(map[string]any) + if source["pointer"] != "/data/attributes/quantity" { + t.Fatalf("pointer = %v", source["pointer"]) + } + + // Original allocation unchanged on GET. + status, body = doJSON(t, h, http.MethodGet, "/v1/ticket_options/"+optionID, nil) + if status != http.StatusOK { + t.Fatalf("get status = %d", status) + } + attrs := body["data"].(map[string]any)["attributes"].(map[string]any) + if int(attrs["allocation"].(float64)) != 2 { + t.Fatalf("allocation changed to %v", attrs["allocation"]) + } +} + +func TestPurchaseMissingTicketOption(t *testing.T) { + t.Parallel() + h := newTestServer(t) + + status, body := doJSON(t, h, http.MethodPost, "/v1/purchases", purchaseBody(uuid.NewString(), uuid.NewString(), 1)) + if status != http.StatusNotFound { + t.Fatalf("status = %d, want 404, body=%v", status, body) + } +} + +func TestPurchaseZeroQuantity(t *testing.T) { + t.Parallel() + h := newTestServer(t) + + optionID := createTicketOption(t, h, "GA", 10) + status, body := doJSON(t, h, http.MethodPost, "/v1/purchases", purchaseBody(optionID, uuid.NewString(), 0)) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body=%v", status, body) + } +} + +func purchaseBody(optionID, userID string, qty int) map[string]any { + return map[string]any{ + "data": map[string]any{ + "type": "purchases", + "attributes": map[string]any{ + "quantity": qty, + }, + "relationships": map[string]any{ + "ticket_option": map[string]any{ + "data": map[string]any{ + "type": "ticket_options", + "id": optionID, + }, + }, + "user": map[string]any{ + "data": map[string]any{ + "type": "users", + "id": userID, + }, + }, + }, + }, + } +} + +func TestPurchaseResponseShape(t *testing.T) { + t.Parallel() + h := newTestServer(t) + + optionID := createTicketOption(t, h, "GA", 5) + userID := uuid.NewString() + status, body := doJSON(t, h, http.MethodPost, "/v1/purchases", purchaseBody(optionID, userID, 1)) + if status != http.StatusCreated { + t.Fatalf("status = %d body=%v", status, body) + } + + data := body["data"].(map[string]any) + if data["type"] != "purchases" { + t.Fatalf("type = %v", data["type"]) + } + if _, err := uuid.Parse(fmt.Sprint(data["id"])); err != nil { + t.Fatalf("id not uuid: %v", data["id"]) + } + rels := data["relationships"].(map[string]any) + to := rels["ticket_option"].(map[string]any)["data"].(map[string]any) + if to["id"] != optionID { + t.Fatalf("ticket_option id = %v", to["id"]) + } +} diff --git a/internal/store/memory/store.go b/internal/store/memory/store.go new file mode 100644 index 0000000..78c0538 --- /dev/null +++ b/internal/store/memory/store.go @@ -0,0 +1,103 @@ +package memory + +import ( + "context" + "sync" + "time" + + "github.com/google/uuid" + + "ticket-allocation/internal/ticketing" +) + +// Store is an in-memory ticketing.Store suitable for unit tests. +type Store struct { + mu sync.Mutex + ticketOptions map[uuid.UUID]ticketing.TicketOption + purchases map[uuid.UUID]ticketing.Purchase +} + +func NewStore() *Store { + return &Store{ + ticketOptions: make(map[uuid.UUID]ticketing.TicketOption), + purchases: make(map[uuid.UUID]ticketing.Purchase), + } +} + +func (s *Store) CreateTicketOption(_ context.Context, params ticketing.CreateTicketOptionParams) (ticketing.TicketOption, error) { + if err := params.Validate(); err != nil { + return ticketing.TicketOption{}, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC() + option := ticketing.TicketOption{ + ID: uuid.New(), + Name: params.Name, + Description: params.Description, + Allocation: params.Allocation, + Purchased: 0, + CreatedAt: now, + UpdatedAt: now, + } + s.ticketOptions[option.ID] = option + return option, nil +} + +func (s *Store) GetTicketOption(_ context.Context, id uuid.UUID) (ticketing.TicketOption, error) { + s.mu.Lock() + defer s.mu.Unlock() + + option, ok := s.ticketOptions[id] + if !ok { + return ticketing.TicketOption{}, ticketing.ErrTicketOptionNotFound + } + return option, nil +} + +func (s *Store) CreatePurchase(_ context.Context, params ticketing.CreatePurchaseParams) (ticketing.Purchase, error) { + if err := params.Validate(); err != nil { + return ticketing.Purchase{}, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + option, ok := s.ticketOptions[params.TicketOptionID] + if !ok { + return ticketing.Purchase{}, ticketing.ErrTicketOptionNotFound + } + if option.Purchased+params.Quantity > option.Allocation { + return ticketing.Purchase{}, ticketing.ErrInsufficientAllocation + } + + now := time.Now().UTC() + option.Purchased += params.Quantity + option.UpdatedAt = now + s.ticketOptions[option.ID] = option + + purchase := ticketing.Purchase{ + ID: uuid.New(), + Quantity: params.Quantity, + UserID: params.UserID, + TicketOptionID: params.TicketOptionID, + CreatedAt: now, + UpdatedAt: now, + } + s.purchases[purchase.ID] = purchase + return purchase, nil +} + +// PurchasedSum returns the sum of purchase quantities for a ticket option (test helper). +func (s *Store) PurchasedSum(ticketOptionID uuid.UUID) int { + s.mu.Lock() + defer s.mu.Unlock() + + option, ok := s.ticketOptions[ticketOptionID] + if !ok { + return 0 + } + return option.Purchased +} diff --git a/internal/store/memory/store_test.go b/internal/store/memory/store_test.go new file mode 100644 index 0000000..15fa562 --- /dev/null +++ b/internal/store/memory/store_test.go @@ -0,0 +1,122 @@ +package memory_test + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + + "github.com/google/uuid" + + "ticket-allocation/internal/store/memory" + "ticket-allocation/internal/ticketing" +) + +func TestCreatePurchaseRespectsAllocation(t *testing.T) { + t.Parallel() + + store := memory.NewStore() + ctx := context.Background() + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: 5, + }) + if err != nil { + t.Fatalf("create option: %v", err) + } + + userID := uuid.New() + _, err = store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 3, + UserID: userID, + TicketOptionID: option.ID, + }) + if err != nil { + t.Fatalf("first purchase: %v", err) + } + + _, err = store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 3, + UserID: userID, + TicketOptionID: option.ID, + }) + if !errors.Is(err, ticketing.ErrInsufficientAllocation) { + t.Fatalf("expected ErrInsufficientAllocation, got %v", err) + } + + got, err := store.GetTicketOption(ctx, option.ID) + if err != nil { + t.Fatalf("get option: %v", err) + } + if got.Purchased != 3 { + t.Fatalf("purchased = %d, want 3", got.Purchased) + } + if got.Allocation != 5 { + t.Fatalf("allocation = %d, want 5", got.Allocation) + } +} + +func TestConcurrentPurchasesDoNotOversell(t *testing.T) { + t.Parallel() + + store := memory.NewStore() + ctx := context.Background() + + const allocation = 100 + const workers = 100 + const qty = 3 + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: allocation, + }) + if err != nil { + t.Fatalf("create option: %v", err) + } + + var ( + wg sync.WaitGroup + successes atomic.Int64 + ) + userID := uuid.New() + + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: qty, + UserID: userID, + TicketOptionID: option.ID, + }) + if err == nil { + successes.Add(1) + return + } + if !errors.Is(err, ticketing.ErrInsufficientAllocation) { + t.Errorf("unexpected error: %v", err) + } + }() + } + wg.Wait() + + maxSuccess := int64(allocation / qty) + if successes.Load() != maxSuccess { + t.Fatalf("successes = %d, want %d", successes.Load(), maxSuccess) + } + if got := store.PurchasedSum(option.ID); got != int(maxSuccess*qty) { + t.Fatalf("purchased sum = %d, want %d", got, maxSuccess*qty) + } +} + +func TestGetTicketOptionNotFound(t *testing.T) { + t.Parallel() + + store := memory.NewStore() + _, err := store.GetTicketOption(context.Background(), uuid.New()) + if !errors.Is(err, ticketing.ErrTicketOptionNotFound) { + t.Fatalf("expected not found, got %v", err) + } +} diff --git a/internal/store/postgres/store.go b/internal/store/postgres/store.go new file mode 100644 index 0000000..b1f1080 --- /dev/null +++ b/internal/store/postgres/store.go @@ -0,0 +1,156 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "ticket-allocation/internal/ticketing" +) + +// Store is a Postgres-backed ticketing.Store. +type Store struct { + db *sqlx.DB +} + +func NewStore(db *sqlx.DB) *Store { + return &Store{db: db} +} + +type ticketOptionRow struct { + ID uuid.UUID `db:"id"` + Name string `db:"name"` + Description string `db:"description"` + Allocation int `db:"allocation"` + Purchased int `db:"purchased"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (r ticketOptionRow) toDomain() ticketing.TicketOption { + return ticketing.TicketOption{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + Allocation: r.Allocation, + Purchased: r.Purchased, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +type purchaseRow struct { + ID uuid.UUID `db:"id"` + Quantity int `db:"quantity"` + UserID uuid.UUID `db:"user_id"` + TicketOptionID uuid.UUID `db:"ticket_option_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (r purchaseRow) toDomain() ticketing.Purchase { + return ticketing.Purchase{ + ID: r.ID, + Quantity: r.Quantity, + UserID: r.UserID, + TicketOptionID: r.TicketOptionID, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func (s *Store) CreateTicketOption(ctx context.Context, params ticketing.CreateTicketOptionParams) (ticketing.TicketOption, error) { + if err := params.Validate(); err != nil { + return ticketing.TicketOption{}, err + } + + const q = ` + INSERT INTO ticket_options (name, description, allocation) + VALUES ($1, $2, $3) + RETURNING id, name, description, allocation, purchased, created_at, updated_at + ` + + var row ticketOptionRow + if err := s.db.GetContext(ctx, &row, q, params.Name, params.Description, params.Allocation); err != nil { + return ticketing.TicketOption{}, fmt.Errorf("create ticket option: %w", err) + } + return row.toDomain(), nil +} + +func (s *Store) GetTicketOption(ctx context.Context, id uuid.UUID) (ticketing.TicketOption, error) { + const q = ` + SELECT id, name, description, allocation, purchased, created_at, updated_at + FROM ticket_options + WHERE id = $1 + ` + + var row ticketOptionRow + err := s.db.GetContext(ctx, &row, q, id) + if errors.Is(err, sql.ErrNoRows) { + return ticketing.TicketOption{}, ticketing.ErrTicketOptionNotFound + } + if err != nil { + return ticketing.TicketOption{}, fmt.Errorf("get ticket option: %w", err) + } + return row.toDomain(), nil +} + +func (s *Store) CreatePurchase(ctx context.Context, params ticketing.CreatePurchaseParams) (ticketing.Purchase, error) { + if err := params.Validate(); err != nil { + return ticketing.Purchase{}, err + } + + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return ticketing.Purchase{}, fmt.Errorf("begin purchase tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + // Atomic conditional update: succeeds only when remaining capacity covers quantity. + const reserveQ = ` + UPDATE ticket_options + SET purchased = purchased + $2, + updated_at = now() + WHERE id = $1 + AND purchased + $2 <= allocation + ` + result, err := tx.ExecContext(ctx, reserveQ, params.TicketOptionID, params.Quantity) + if err != nil { + return ticketing.Purchase{}, fmt.Errorf("reserve allocation: %w", err) + } + + affected, err := result.RowsAffected() + if err != nil { + return ticketing.Purchase{}, fmt.Errorf("reserve allocation rows affected: %w", err) + } + if affected == 0 { + var exists bool + if err := tx.GetContext(ctx, &exists, `SELECT EXISTS(SELECT 1 FROM ticket_options WHERE id = $1)`, params.TicketOptionID); err != nil { + return ticketing.Purchase{}, fmt.Errorf("check ticket option exists: %w", err) + } + if !exists { + return ticketing.Purchase{}, ticketing.ErrTicketOptionNotFound + } + return ticketing.Purchase{}, ticketing.ErrInsufficientAllocation + } + + const insertQ = ` + INSERT INTO purchases (quantity, user_id, ticket_option_id) + VALUES ($1, $2, $3) + RETURNING id, quantity, user_id, ticket_option_id, created_at, updated_at + ` + var row purchaseRow + if err := tx.GetContext(ctx, &row, insertQ, params.Quantity, params.UserID, params.TicketOptionID); err != nil { + return ticketing.Purchase{}, fmt.Errorf("insert purchase: %w", err) + } + + if err := tx.Commit(); err != nil { + return ticketing.Purchase{}, fmt.Errorf("commit purchase: %w", err) + } + return row.toDomain(), nil +} diff --git a/internal/store/postgres/store_integration_test.go b/internal/store/postgres/store_integration_test.go new file mode 100644 index 0000000..d5f615e --- /dev/null +++ b/internal/store/postgres/store_integration_test.go @@ -0,0 +1,550 @@ +//go:build integration + +package postgres_test + +import ( + "context" + "database/sql" + "errors" + "os" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + + "ticket-allocation/internal/store/postgres" + "ticket-allocation/internal/ticketing" +) + +func openTestDB(t *testing.T) *sqlx.DB { + t.Helper() + + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + + db, err := sqlx.Connect("pgx", dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + // Cap the pool so concurrent hammers queue instead of exhausting Postgres max_connections. + db.SetMaxOpenConns(20) + db.SetMaxIdleConns(20) + db.SetConnMaxLifetime(5 * time.Minute) + t.Cleanup(func() { _ = db.Close() }) + return db +} + +func truncate(t *testing.T, db *sqlx.DB) { + t.Helper() + if _, err := db.Exec(`TRUNCATE purchases, ticket_options CASCADE`); err != nil { + t.Fatalf("truncate: %v", err) + } +} + +func TestPostgresCreateGetAndPurchase(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + ctx := context.Background() + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Description: "general admission", + Allocation: 10, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + got, err := store.GetTicketOption(ctx, option.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.Allocation != 10 || got.Name != "GA" { + t.Fatalf("unexpected option: %+v", got) + } + + purchase, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 4, + UserID: uuid.New(), + TicketOptionID: option.ID, + }) + if err != nil { + t.Fatalf("purchase: %v", err) + } + if purchase.Quantity != 4 { + t.Fatalf("quantity = %d", purchase.Quantity) + } + + got, err = store.GetTicketOption(ctx, option.ID) + if err != nil { + t.Fatalf("get after purchase: %v", err) + } + if got.Purchased != 4 { + t.Fatalf("purchased = %d, want 4", got.Purchased) + } + if got.Allocation != 10 { + t.Fatalf("allocation mutated: %d", got.Allocation) + } +} + +func TestPostgresInsufficientAllocation(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + ctx := context.Background() + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: 2, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + _, err = store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 3, + UserID: uuid.New(), + TicketOptionID: option.ID, + }) + if !errors.Is(err, ticketing.ErrInsufficientAllocation) { + t.Fatalf("expected insufficient allocation, got %v", err) + } + + var purchased int + if err := db.Get(&purchased, `SELECT purchased FROM ticket_options WHERE id = $1`, option.ID); err != nil { + t.Fatalf("select purchased: %v", err) + } + if purchased != 0 { + t.Fatalf("purchased = %d, want 0", purchased) + } + + var count int + if err := db.Get(&count, `SELECT COUNT(*) FROM purchases WHERE ticket_option_id = $1`, option.ID); err != nil { + t.Fatalf("count purchases: %v", err) + } + if count != 0 { + t.Fatalf("purchase rows = %d, want 0", count) + } +} + +func TestPostgresPurchaseMissingOption(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + + _, err := store.CreatePurchase(context.Background(), ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: uuid.New(), + TicketOptionID: uuid.New(), + }) + if !errors.Is(err, ticketing.ErrTicketOptionNotFound) { + t.Fatalf("expected not found, got %v", err) + } +} + +func TestPostgresConcurrentPurchasesDoNotOversell(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + ctx := context.Background() + + const allocation = 100 + const workers = 100 + const qty = 3 + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: allocation, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + var ( + wg sync.WaitGroup + successes atomic.Int64 + ) + userID := uuid.New() + + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: qty, + UserID: userID, + TicketOptionID: option.ID, + }) + switch { + case err == nil: + successes.Add(1) + case errors.Is(err, ticketing.ErrInsufficientAllocation): + // expected under contention + default: + t.Errorf("unexpected error: %v", err) + } + }() + } + wg.Wait() + + wantSuccess := int64(allocation / qty) + if successes.Load() != wantSuccess { + t.Fatalf("successes = %d, want %d", successes.Load(), wantSuccess) + } + + var purchased int + if err := db.Get(&purchased, `SELECT purchased FROM ticket_options WHERE id = $1`, option.ID); err != nil { + t.Fatalf("select purchased: %v", err) + } + if purchased != int(wantSuccess*qty) { + t.Fatalf("purchased counter = %d, want %d", purchased, wantSuccess*qty) + } + + var sum sql.NullInt64 + if err := db.Get(&sum, `SELECT COALESCE(SUM(quantity), 0) FROM purchases WHERE ticket_option_id = $1`, option.ID); err != nil { + t.Fatalf("sum quantities: %v", err) + } + if sum.Int64 != wantSuccess*qty { + t.Fatalf("sum(quantity) = %d, want %d", sum.Int64, wantSuccess*qty) + } + if sum.Int64 > allocation { + t.Fatalf("oversold: sum=%d allocation=%d", sum.Int64, allocation) + } +} + +func TestPostgresExactSellOut(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + ctx := context.Background() + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: 10, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + userID := uuid.New() + for _, qty := range []int{7, 3} { + if _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: qty, + UserID: userID, + TicketOptionID: option.ID, + }); err != nil { + t.Fatalf("purchase qty=%d: %v", qty, err) + } + } + + _, err = store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: userID, + TicketOptionID: option.ID, + }) + if !errors.Is(err, ticketing.ErrInsufficientAllocation) { + t.Fatalf("expected insufficient allocation after sell-out, got %v", err) + } + + assertPurchasedMatchesSum(t, db, option.ID, 10) +} + +func TestPostgresConcurrentExactBoundary(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + ctx := context.Background() + + const allocation = 50 + const workers = 50 + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: allocation, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + var ( + wg sync.WaitGroup + successes atomic.Int64 + ) + userID := uuid.New() + + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: userID, + TicketOptionID: option.ID, + }) + switch { + case err == nil: + successes.Add(1) + case errors.Is(err, ticketing.ErrInsufficientAllocation): + // should not happen when workers == allocation and qty == 1 + t.Errorf("unexpected insufficient allocation with exact boundary") + default: + t.Errorf("unexpected error: %v", err) + } + }() + } + wg.Wait() + + if successes.Load() != allocation { + t.Fatalf("successes = %d, want %d", successes.Load(), allocation) + } + assertPurchasedMatchesSum(t, db, option.ID, allocation) +} + +func TestPostgresIndependentOptionsUnderContention(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + ctx := context.Background() + + const ( + allocationA = 40 + allocationB = 60 + workersA = 60 + workersB = 80 + qty = 1 + ) + + optionA, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "A", + Allocation: allocationA, + }) + if err != nil { + t.Fatalf("create A: %v", err) + } + optionB, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "B", + Allocation: allocationB, + }) + if err != nil { + t.Fatalf("create B: %v", err) + } + + var ( + wg sync.WaitGroup + successesA atomic.Int64 + successesB atomic.Int64 + ) + userID := uuid.New() + + wg.Add(workersA + workersB) + for i := 0; i < workersA; i++ { + go func() { + defer wg.Done() + _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: qty, + UserID: userID, + TicketOptionID: optionA.ID, + }) + switch { + case err == nil: + successesA.Add(1) + case errors.Is(err, ticketing.ErrInsufficientAllocation): + default: + t.Errorf("option A unexpected error: %v", err) + } + }() + } + for i := 0; i < workersB; i++ { + go func() { + defer wg.Done() + _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: qty, + UserID: userID, + TicketOptionID: optionB.ID, + }) + switch { + case err == nil: + successesB.Add(1) + case errors.Is(err, ticketing.ErrInsufficientAllocation): + default: + t.Errorf("option B unexpected error: %v", err) + } + }() + } + wg.Wait() + + if successesA.Load() != allocationA { + t.Fatalf("option A successes = %d, want %d", successesA.Load(), allocationA) + } + if successesB.Load() != allocationB { + t.Fatalf("option B successes = %d, want %d", successesB.Load(), allocationB) + } + + assertPurchasedMatchesSum(t, db, optionA.ID, allocationA) + assertPurchasedMatchesSum(t, db, optionB.ID, allocationB) + + // No cross-contamination: every purchase row must reference exactly one of the two options, + // and counts must match the per-option counters. + var foreignOrphan int + if err := db.Get(&foreignOrphan, ` + SELECT COUNT(*) FROM purchases + WHERE ticket_option_id NOT IN ($1, $2) + `, optionA.ID, optionB.ID); err != nil { + t.Fatalf("count orphan FKs: %v", err) + } + if foreignOrphan != 0 { + t.Fatalf("found %d purchases with unexpected ticket_option_id", foreignOrphan) + } +} + +func TestPostgresZeroAllocation(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + ctx := context.Background() + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "SoldOut", + Allocation: 0, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + _, err = store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: uuid.New(), + TicketOptionID: option.ID, + }) + if !errors.Is(err, ticketing.ErrInsufficientAllocation) { + t.Fatalf("expected insufficient allocation, got %v", err) + } + + assertPurchasedMatchesSum(t, db, option.ID, 0) + + var count int + if err := db.Get(&count, `SELECT COUNT(*) FROM purchases WHERE ticket_option_id = $1`, option.ID); err != nil { + t.Fatalf("count purchases: %v", err) + } + if count != 0 { + t.Fatalf("purchase rows = %d, want 0", count) + } +} + +// TestPostgresCancelUnderLoad cancels an in-flight purchase storm and asserts the +// purchased counter never diverges from SUM(purchases.quantity) — i.e. cancelled +// transactions roll back both the counter bump and the purchase insert together. +func TestPostgresCancelUnderLoad(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + + const allocation = 100 + const workers = 100 + + option, err := store.CreateTicketOption(context.Background(), ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: allocation, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var ( + wg sync.WaitGroup + successes atomic.Int64 + cancels atomic.Int64 + otherErrs atomic.Int64 + gate = make(chan struct{}) + ) + userID := uuid.New() + + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + <-gate + _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: userID, + TicketOptionID: option.ID, + }) + switch { + case err == nil: + successes.Add(1) + case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): + cancels.Add(1) + case errors.Is(err, ticketing.ErrInsufficientAllocation): + // possible if cancel is late and capacity is exhausted + case ctx.Err() != nil: + // Driver may surface sql.ErrTxDone / "already rolled back" after cancel. + cancels.Add(1) + default: + otherErrs.Add(1) + t.Errorf("unexpected error: %v", err) + } + }() + } + + close(gate) + // Let some transactions begin acquiring connections / row locks, then cancel. + time.Sleep(5 * time.Millisecond) + cancel() + wg.Wait() + + if otherErrs.Load() != 0 { + t.Fatalf("saw %d unexpected errors", otherErrs.Load()) + } + if successes.Load() == 0 && cancels.Load() == 0 { + t.Fatal("expected some successes and/or cancellations") + } + + var purchased int + if err := db.Get(&purchased, `SELECT purchased FROM ticket_options WHERE id = $1`, option.ID); err != nil { + t.Fatalf("select purchased: %v", err) + } + var sum sql.NullInt64 + if err := db.Get(&sum, `SELECT COALESCE(SUM(quantity), 0) FROM purchases WHERE ticket_option_id = $1`, option.ID); err != nil { + t.Fatalf("sum quantities: %v", err) + } + if int64(purchased) != sum.Int64 { + t.Fatalf("counter/purchase divergence after cancel: purchased=%d sum=%d (successes=%d cancels=%d)", + purchased, sum.Int64, successes.Load(), cancels.Load()) + } + if purchased > allocation { + t.Fatalf("oversold after cancel: purchased=%d allocation=%d", purchased, allocation) + } + if int64(purchased) != successes.Load() { + t.Fatalf("purchased=%d does not match observed successes=%d", purchased, successes.Load()) + } +} + +func assertPurchasedMatchesSum(t *testing.T, db *sqlx.DB, ticketOptionID uuid.UUID, want int) { + t.Helper() + + var purchased int + if err := db.Get(&purchased, `SELECT purchased FROM ticket_options WHERE id = $1`, ticketOptionID); err != nil { + t.Fatalf("select purchased: %v", err) + } + if purchased != want { + t.Fatalf("purchased = %d, want %d", purchased, want) + } + + var sum sql.NullInt64 + if err := db.Get(&sum, `SELECT COALESCE(SUM(quantity), 0) FROM purchases WHERE ticket_option_id = $1`, ticketOptionID); err != nil { + t.Fatalf("sum quantities: %v", err) + } + if sum.Int64 != int64(want) { + t.Fatalf("sum(quantity) = %d, want %d", sum.Int64, want) + } +} diff --git a/internal/ticketing/ticketing.go b/internal/ticketing/ticketing.go new file mode 100644 index 0000000..88a8c8e --- /dev/null +++ b/internal/ticketing/ticketing.go @@ -0,0 +1,109 @@ +package ticketing + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + ErrTicketOptionNotFound = errors.New("ticket option not found") + ErrInsufficientAllocation = errors.New("insufficient allocation") + ErrInvalidInput = errors.New("invalid input") +) + +// InvalidInputError carries field-level detail for JSON:API error mapping. +type InvalidInputError struct { + Pointer string + Detail string +} + +func (e *InvalidInputError) Error() string { + if e.Detail != "" { + return e.Detail + } + return ErrInvalidInput.Error() +} + +func (e *InvalidInputError) Unwrap() error { + return ErrInvalidInput +} + +type TicketOption struct { + ID uuid.UUID + Name string + Description string + Allocation int + Purchased int + CreatedAt time.Time + UpdatedAt time.Time +} + +type Purchase struct { + ID uuid.UUID + Quantity int + UserID uuid.UUID + TicketOptionID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time +} + +type CreateTicketOptionParams struct { + Name string + Description string + Allocation int +} + +func (p CreateTicketOptionParams) Validate() error { + if strings.TrimSpace(p.Name) == "" { + return &InvalidInputError{ + Pointer: "/data/attributes/name", + Detail: "name is required", + } + } + if p.Allocation < 0 { + return &InvalidInputError{ + Pointer: "/data/attributes/allocation", + Detail: "allocation must be a non-negative integer", + } + } + return nil +} + +type CreatePurchaseParams struct { + Quantity int + UserID uuid.UUID + TicketOptionID uuid.UUID +} + +func (p CreatePurchaseParams) Validate() error { + if p.Quantity <= 0 { + return &InvalidInputError{ + Pointer: "/data/attributes/quantity", + Detail: "quantity must be a positive integer", + } + } + if p.TicketOptionID == uuid.Nil { + return &InvalidInputError{ + Pointer: "/data/relationships/ticket_option/data/id", + Detail: "ticket_option id is required", + } + } + if p.UserID == uuid.Nil { + return &InvalidInputError{ + Pointer: "/data/relationships/user/data/id", + Detail: "user id is required", + } + } + return nil +} + +// Store persists ticket options and purchases. +type Store interface { + CreateTicketOption(ctx context.Context, params CreateTicketOptionParams) (TicketOption, error) + GetTicketOption(ctx context.Context, id uuid.UUID) (TicketOption, error) + CreatePurchase(ctx context.Context, params CreatePurchaseParams) (Purchase, error) +} diff --git a/internal/ticketing/ticketing_test.go b/internal/ticketing/ticketing_test.go new file mode 100644 index 0000000..55b1668 --- /dev/null +++ b/internal/ticketing/ticketing_test.go @@ -0,0 +1,86 @@ +package ticketing_test + +import ( + "errors" + "testing" + + "ticket-allocation/internal/ticketing" + + "github.com/google/uuid" +) + +func TestCreateTicketOptionParamsValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + params ticketing.CreateTicketOptionParams + wantErr bool + pointer string + }{ + { + name: "valid", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 10}, + wantErr: false, + }, + { + name: "missing name", + params: ticketing.CreateTicketOptionParams{Name: " ", Allocation: 10}, + wantErr: true, + pointer: "/data/attributes/name", + }, + { + name: "negative allocation", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: -1}, + wantErr: true, + pointer: "/data/attributes/allocation", + }, + { + name: "zero allocation allowed", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 0}, + wantErr: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := tt.params.Validate() + if tt.wantErr && err == nil { + t.Fatal("expected error") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.wantErr { + var invalid *ticketing.InvalidInputError + if !errors.As(err, &invalid) { + t.Fatalf("expected InvalidInputError, got %T", err) + } + if invalid.Pointer != tt.pointer { + t.Fatalf("pointer = %q, want %q", invalid.Pointer, tt.pointer) + } + } + }) + } +} + +func TestCreatePurchaseParamsValidate(t *testing.T) { + t.Parallel() + + valid := ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: uuid.New(), + TicketOptionID: uuid.New(), + } + if err := valid.Validate(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + zeroQty := valid + zeroQty.Quantity = 0 + if err := zeroQty.Validate(); err == nil { + t.Fatal("expected error for zero quantity") + } +} From 86b802ece796fefe3d5187d7b4ea2dbab5f8c3c9 Mon Sep 17 00:00:00 2001 From: Morgan Slater Date: Sun, 12 Jul 2026 14:08:39 +0100 Subject: [PATCH 4/5] feat: add bucketed allocation Co-authored-by: Cursor Grok 4.5 --- Makefile | 15 +- README.md | 321 +++++------------ Ticket_Allocation.postman_collection.json | 9 +- database_structure.sql | 4 + db/init/001_schema.sql | 36 +- go.mod | 1 + go.sum | 2 + .../api/v1/Ticket_Allocation.swagger.yaml | 8 + internal/api/v1/api.gen.go | 5 +- internal/api/v1/handler.go | 1 + internal/api/v1/handler_test.go | 47 +++ internal/store/memory/store.go | 134 ++++++- internal/store/memory/store_test.go | 67 +++- internal/store/postgres/store.go | 255 +++++++++++-- internal/store/postgres/store_bench_test.go | 102 ++++++ .../store/postgres/store_integration_test.go | 334 +++++++++++------- internal/ticketing/ticketing.go | 82 ++++- internal/ticketing/ticketing_test.go | 105 +++++- 18 files changed, 1088 insertions(+), 440 deletions(-) create mode 100644 internal/store/postgres/store_bench_test.go diff --git a/Makefile b/Makefile index e2b20c2..fa3aab3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: generate local run test test-integration docker-up docker-down +.PHONY: generate local run test test-integration bench-purchase docker-up docker-down DATABASE_URL ?= postgres://ticket:ticket@localhost:5432/ticket_allocation?sslmode=disable PORT ?= 3000 @@ -12,8 +12,21 @@ local: @echo "API listening on http://localhost:3000" @echo "Health: curl -s http://localhost:3000/_health" +docker-up: + docker compose up -d --wait postgres + +docker-down: + docker compose down -v + +# Run the API on the host against compose Postgres. +run: docker-up + DATABASE_URL="$(DATABASE_URL)" PORT="$(PORT)" go run ./cmd/server + test: go test ./... -race -count=1 test-integration: docker-up TEST_DATABASE_URL="$(DATABASE_URL)" go test ./internal/store/postgres/... -tags=integration -race -count=1 -timeout=120s + +bench-purchase: docker-up + TEST_DATABASE_URL="$(DATABASE_URL)" go test ./internal/store/postgres/... -tags=integration -run=^$$ -bench=BenchmarkCreatePurchaseContention -benchmem -count=3 -timeout=180s diff --git a/README.md b/README.md index ca461cd..7fe167a 100644 --- a/README.md +++ b/README.md @@ -1,274 +1,135 @@ -# Ticket Allocation Coding Test +# Ticket Allocation API -## Introduction +Spec-first OpenAPI, Postgres, bucketed inventory for flash-sale contention. -Thanks for applying for a development role at Fatsoma. To give us a good -indication of your programming ability and style, please submit your solution -for this ticket allocation problem. We are mainly looking for clean, well -architected, tested code that highlights your skill set and shows technical -proficiency. +## Requirements -This is not a timed test, but you do not need to spend more than a couple of -hours on it. We have tried to keep the scope small so that time is spent on the -interesting parts rather than on boilerplate. A partial solution is still very -useful — if you run out of time, describe in your submission what you would do -next. +- Go 1.25+ — only needed for host-side `make run` / tests (`go.mod` / Docker builder) +- Docker / Docker Compose -Your submission must be your own work. +## How to run -### Use of AI tools +Full stack (Postgres + API) on the docker network: -Using AI tools (e.g. GitHub Copilot, ChatGPT, Claude) is allowed — they are part -of how we work day to day. However, please **declare any AI co-authorship** in -your submission notes: briefly note where and how you used AI (for example, -scaffolding, generating tests, or rubber-ducking the locking approach). You -remain responsible for everything you submit, so make sure you understand and -can explain your solution — we will likely discuss it when we meet. - - -### Languages and frameworks - -For reference, here at Fatsoma we primarily develop using Ruby on Rails and -Go. However, feel free to solve this in whatever language you are most -comfortable with, and use a framework if you wish. We are interested in how you -structure and reason about the solution, not in any particular stack. - -## Problem definition +```bash +make local +``` -The following three routes need to be built to enable allocating ticket options -to multiple purchases. They all correspond to the [JSON:API spec](https://jsonapi.org/). -If helpful, a list of JSON:API implementation libraries across various languages -is available at https://jsonapi.org/implementations/. We're not assessing strict -conformance to the spec, so minor differences in the output produced by your -chosen library are fine. +This builds the API image, starts Postgres and the API, and waits until both are healthy. The API is at `http://localhost:3000`. -A `ticket_option` is created with a fixed `allocation` — the total number of -tickets available to purchase. Each `purchase` draws a `quantity` of tickets -against a `ticket_option`. +### Helpful commands -The solution must guarantee the following invariant at all times: +Spin up the API locally: +```bash +make local +``` -> The sum of purchased quantities for a `ticket_option` must never exceed its -> `allocation`, and the remaining availability must never drop below 0. +- Health check: `GET http://localhost:3000/_health` -The `allocation` value on a `ticket_option` represents its **total** capacity -and does not change once created; remaining availability is derived from the -allocation minus the quantities already purchased. The `GET` route returns the -`ticket_option` as created (i.e. the original `allocation`). +Generate handler code using the Open API specification: -Expect requests to be made against this API concurrently, and design it to scale -horizontally: multiple instances of the program may run at once to handle large -numbers of requests. The invariant above must hold regardless of how many -requests, or how many running instances, are involved — including when many -purchases hit the same `ticket_option` at the same time. +```bash +make generate +``` -We use the term "purchase", but taking payment is out of scope. You do not need -to build authentication, payment handling, a UI, or anything beyond the three -routes described below. +### Example requests -### Database +```bash +# Create ticket option (bucket_count optional; defaults to 1) +curl -s -X POST http://localhost:3000/v1/ticket_options \ + -H 'Content-Type: application/vnd.api+json' \ + -d '{"data":{"type":"ticket_options","attributes":{"name":"example","description":"sample","allocation":100,"bucket_count":8}}}' -Included is an SQL dump from PostgreSQL (`database_structure.sql`). You are not -required to use it, but it should be a helpful starting point. Note that it -intentionally ships **without** integrity constraints (no foreign keys, nullable -columns, no `CHECK`s) — part of the exercise is deciding what constraints the -schema should have. +# Get ticket option +curl -s http://localhost:3000/v1/ticket_options/ -The SQL dump is provided purely as an example. Whatever storage you use, it must -let you prevent overselling a `ticket_option`'s allocation under concurrent load -across multiple instances. Beyond that, you may use any database engine or -storage approach you like. +# Purchase +curl -s -X POST http://localhost:3000/v1/purchases \ + -H 'Content-Type: application/vnd.api+json' \ + -d '{"data":{"type":"purchases","attributes":{"quantity":2},"relationships":{"ticket_option":{"data":{"type":"ticket_options","id":""}},"user":{"data":{"type":"users","id":"d6abe829-c28c-44ec-bee6-3183f2c53fef"}}}}}' +``` -## Routes with example requests +## How to run the tests -All requests and responses use the JSON:API media type -`application/vnd.api+json`. The Swagger definition -(`Ticket_Allocation.swagger.yaml`) and Postman collection -(`Ticket_Allocation.postman_collection.json`) are also available in this -repository for reference. +```bash +# Unit tests (fake in-memory store + HTTP handler tests, with the race detector) +make test -### Create ticket option +# Integration tests against real Postgres +make test-integration -Create a `ticket_option` with an allocation of tickets available to purchase: +# Purchase throughput: bucket_count=1 vs 32 (~64 concurrent buyers) +make bench-purchase +``` -`POST /ticket_options` +### Benchmark results (`make bench-purchase`) -Request Body: +To justify the bucketed allocation solution, we used the following benchmarks. -```json -{ - "data": { - "type": "ticket_options", - "attributes": { - "name": "example", - "description": "sample description", - "allocation": 100 - } - } -} -``` +Local run on Apple M5 / Darwin arm64, Postgres via Docker, `-count=3`, ~64 concurrent buyers: -Response Body (`201 Created`): - -```json -{ - "data": { - "type": "ticket_options", - "id": "70b751fe-04dd-4dd1-8955-ab9b188ddb1f", - "attributes": { - "name": "example", - "description": "sample description", - "allocation": 100 - } - } -} -``` +| Config | Run | ns/op | B/op | allocs/op | ≈ purchases/sec | +|---|---:|---:|---:|---:|---:| +| `bucket_count=1` | 1 | 1,279,574 | 15,425 | 265 | ~780 | +| `bucket_count=1` | 2 | 1,234,176 | 15,558 | 266 | ~810 | +| `bucket_count=1` | 3 | 1,516,179 | 17,694 | 274 | ~660 | +| `bucket_count=32` | 1 | 450,953 | 21,842 | 501 | ~2,220 | +| `bucket_count=32` | 2 | 273,280 | 21,277 | 503 | ~3,660 | +| `bucket_count=32` | 3 | 277,221 | 21,172 | 505 | ~3,610 | -`allocation` must be a non-negative integer. Requests with a missing `name` or a -negative/non-integer `allocation` should return a `4xx` error. +Analysis: -### Get ticket option +- Under this load, **32 buckets are roughly 3–4× faster** than a single bucket (lower `ns/op` → higher throughput). +- With one bucket, ~64 buyers serialise on the same row lock; with 32 buckets, many purchases hit different rows and proceed in parallel. +- **32 buckets allocate more** (~500 allocs/op vs ~270) because more bucket rows are loaded/probed — lock wait still dominates latency, so throughput still wins. +- Numbers are noisy on local Docker (see run-to-run spread); treat them as a directional comparison, not a lab-grade SLA. -Get ticket option by id: +## Design notes -`GET /ticket_options/:id` +The philosophy here is: unit test everything above the persistence layer in isolation, then integration test the persistence layer. -(No request body) +Instead of writing handlers manually, `internal/api/v1/Ticket_Allocation.swagger.yaml` is the single source of truth for endpoints. -Response Body (`200 OK`): +### No-oversell invariant (bucketed capacity) -```json -{ - "data": { - "type": "ticket_options", - "id": "70b751fe-04dd-4dd1-8955-ab9b188ddb1f", - "attributes": { - "name": "example", - "description": "sample description", - "allocation": 100 - } - } -} -``` +Total capacity (`allocation`) is fixed on `ticket_options`. At create time it is split across `bucket_count` rows in `ticket_option_buckets` (default **1**, max **32**, and `bucket_count <= allocation` so no empty buckets). -A request for an id that does not exist should return a `404 Not Found`. - -### Purchase from ticket option - -Purchase a quantity of tickets from the allocation of the given `ticket_option` -and associate it with a user (N.B. managing a user resource is not being looked -at here, so an example id is sufficient): - -`POST /purchases` - -Request body: - -```json -{ - "data": { - "type": "purchases", - "attributes": { - "quantity": 2 - }, - "relationships": { - "ticket_option": { - "data": { - "type": "ticket_options", - "id": "969f4317-09f4-4b15-b8be-a87d40fb56fb" - } - }, - "user": { - "data": { - "type": "users", - "id": "d6abe829-c28c-44ec-bee6-3183f2c53fef" - } - } - } - } -} -``` +Each bucket has its own `purchased` counter with: -Response Body: - -A `201 Created` status code must be returned on success. - -```json -{ - "data": { - "type": "purchases", - "id": "cd837712-fd86-4345-9e7f-d519c8db6c45", - "attributes": { - "quantity": 2 - }, - "relationships": { - "ticket_option": { - "data": { - "type": "ticket_options", - "id": "969f4317-09f4-4b15-b8be-a87d40fb56fb" - } - }, - "user": { - "data": { - "type": "users", - "id": "d6abe829-c28c-44ec-bee6-3183f2c53fef" - } - } - } - } -} +```sql +CHECK (purchased >= 0 AND purchased <= capacity) ``` -A `4xx` status code must be returned on any request that attempts to purchase -more tickets than are available. In this case, no tickets should be purchased -for that request. Example error response given below: - -```json -{ - "errors": [ - { - "status": "400", - "code": "invalid_purchase_quantity", - "title": "Unable to purchase provided quantity", - "detail": "Unable to reserve given quantity of ticket options", - "source": { - "pointer": "/data/attributes/quantity" - } - } - ] -} -``` +Purchases run in a single transaction: -### Validation and error handling +1. **Fast path:** try to fit the entire quantity in one bucket via conditional `UPDATE` (random start index, probe all buckets) +2. **Slow path:** if no single bucket fits, lock buckets in `bucket_index` order and fill across multiple buckets - This is likely to occur as the event sells out. +3. Insert one `purchases` row plus `purchase_allocations` lines for each bucket touched +4. On shortfall, roll back — nothing persisted -Please handle at least the following cases with an appropriate `4xx` status and -a JSON:API `errors` payload (the shape above is a good template): +This spreads write contention across buckets under concurrent load while remaining correct across horizontally scaled API instances. With `bucket_count=1`, behaviour matches a single-row counter. -- `quantity` that is zero or negative. -- A purchase referencing a `ticket_option` id that does not exist - (`404 Not Found`). -- A request that would exceed the remaining allocation (`code: - invalid_purchase_quantity`, as above) — no tickets persisted. -- Missing required attributes on create (e.g. `name`, `allocation`). +`GET /v1/ticket_options/:id` returns the original `allocation` (total capacity), not remaining availability or `bucket_count`, as required by the brief. -You do not need to exhaustively validate every field; we are more interested in -seeing a consistent, sensible approach than full coverage. +### Layering -## Submitting your solution +- `internal/ticketing` — domain types, validation (`bucket_count` rules, `SplitAllocation`), sentinel errors, `Store` interface +- `internal/store/postgres` — sqlx + pgx/stdlib implementation +- `internal/store/memory` — mutex-backed fake for unit tests +- `internal/api/v1` — oapi-codegen (`types`, `std-http-server`, `strict-server`) plus hand-written handler / JSON:API error mapping +- `cmd/server` — wiring, config, graceful shutdown -Please include a short README in your submission covering: +### Assumptions -1. **How to run it** — setup steps, dependencies, and how to start the API - (including any database setup/migrations). -2. **How to run the tests.** -3. **Notes** — any assumptions you made, trade-offs, what you would do next - given more time, and a short declaration of any AI tool usage (see - [Use of AI tools](#use-of-ai-tools)). +- User resources are not managed; any UUID is accepted as `user.id` (per brief) +- `description` defaults to empty string when omitted +- `bucket_count` defaults to `1` when omitted and `allocation > 0`; must be `0` when `allocation` is `0` +- Allocation of `0` is allowed; purchases against it fail with insufficient allocation +- JSON:API response schemas live in the OpenAPI spec so codegen owns serialisation shapes -Submit your solution as a link to a Git repository (e.g. GitHub) or as a -zip/tarball, whichever is easier for you. If you use a private repository, please -invite the GitHub user `fatsoma-review` so we can access it. Please do not -include compiled artifacts or dependency directories (e.g. `node_modules`, -`vendor`). +### Trade-offs -If anything in this brief is unclear, make a reasonable assumption, state it in -your notes, and carry on — we are happy to discuss your reasoning when we meet. +- **Bucketed denormalised counters** vs `SUM(purchases.quantity)`: O(1) reservation per bucket and higher write throughput on hot options; the trade-off is keeping bucket counters consistent with `purchase_allocations` (done in one transaction) and more complex multi-bucket fills +- **Configurable `bucket_count`** (default 1): preserves simple single-row semantics by default; operators can opt into sharding up to 32 buckets +- **stdlib `net/http` routing** (Go 1.22+) over a third-party router: less dependency surface; oapi-codegen's `std-http-server` generator matches this choice +- **sqlx over an ORM**: explicit SQL for the critical path, still ergonomic scanning diff --git a/Ticket_Allocation.postman_collection.json b/Ticket_Allocation.postman_collection.json index b32313f..af9b657 100644 --- a/Ticket_Allocation.postman_collection.json +++ b/Ticket_Allocation.postman_collection.json @@ -18,12 +18,13 @@ } ], "url": { - "raw": "localhost:3000/ticket_options/{{ticketOptionID}}", + "raw": "localhost:3000/v1/ticket_options/{{ticketOptionID}}", "host": [ "localhost" ], "port": "3000", "path": [ + "v1", "ticket_options", "{{ticketOptionID}}" ] @@ -61,12 +62,13 @@ "raw": "{\n \"data\": {\n \"type\": \"ticket_options\",\n \"attributes\": {\n \"name\": \"Test ticket option\",\n \"description\": \"ticket option description\",\n \"allocation\": 100\n }\n }\n}" }, "url": { - "raw": "localhost:3000/ticket_options", + "raw": "localhost:3000/v1/ticket_options", "host": [ "localhost" ], "port": "3000", "path": [ + "v1", "ticket_options" ] }, @@ -89,12 +91,13 @@ "raw": "{\n \"data\": {\n \"type\": \"purchases\",\n \"attributes\": {\n \"quantity\": 1\n },\n \"relationships\": {\n \"user\": {\n \"data\": {\n \"type\": \"users\",\n \"id\": \"d6abe829-c28c-44ec-bee6-3183f2c53fef\"\n }\n },\n \"ticket_option\": {\n \"data\": {\n \"type\": \"ticket_options\",\n \"id\": \"{{ticketOptionID}}\"\n }\n }\n }\n }\n}" }, "url": { - "raw": "localhost:3000/purchases", + "raw": "localhost:3000/v1/purchases", "host": [ "localhost" ], "port": "3000", "path": [ + "v1", "purchases" ] }, diff --git a/database_structure.sql b/database_structure.sql index f571739..00b4f2d 100644 --- a/database_structure.sql +++ b/database_structure.sql @@ -1,4 +1,8 @@ -- +-- ORIGINAL BRIEF ARTIFACT — do not use for local setup. +-- Superseded by db/init/001_schema.sql (bucketed capacity, CHECKs, FKs). +-- Kept only as the schema dump that shipped with the take-home brief. +-- -- PostgreSQL database dump -- diff --git a/db/init/001_schema.sql b/db/init/001_schema.sql index 9a20e61..42e8a75 100644 --- a/db/init/001_schema.sql +++ b/db/init/001_schema.sql @@ -1,6 +1,5 @@ --- Ticket allocation schema with integrity constraints. --- The purchased counter is denormalised for O(1) atomic reservation; --- CHECK (purchased <= allocation) is the belt-and-braces backstop. +-- Ticket allocation schema with bucketed capacity for write sharding. +-- Capacity is split across ticket_option_buckets at create time. CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; @@ -9,11 +8,22 @@ CREATE TABLE public.ticket_options ( name character varying NOT NULL, description character varying NOT NULL DEFAULT '', allocation integer NOT NULL CHECK (allocation >= 0), + bucket_count integer NOT NULL DEFAULT 1 CHECK (bucket_count >= 0 AND bucket_count <= 32), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE public.ticket_option_buckets ( + id uuid DEFAULT public.uuid_generate_v4() PRIMARY KEY, + ticket_option_id uuid NOT NULL REFERENCES public.ticket_options (id), + bucket_index integer NOT NULL, + capacity integer NOT NULL CHECK (capacity >= 1), purchased integer NOT NULL DEFAULT 0, - created_at timestamp without time zone NOT NULL DEFAULT now(), - updated_at timestamp without time zone NOT NULL DEFAULT now(), - CONSTRAINT ticket_options_purchased_bounds - CHECK (purchased >= 0 AND purchased <= allocation) + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (ticket_option_id, bucket_index), + CONSTRAINT ticket_option_buckets_purchased_bounds + CHECK (purchased >= 0 AND purchased <= capacity) ); CREATE TABLE public.purchases ( @@ -21,8 +31,16 @@ CREATE TABLE public.purchases ( quantity integer NOT NULL CHECK (quantity > 0), user_id uuid NOT NULL, ticket_option_id uuid NOT NULL REFERENCES public.ticket_options (id), - created_at timestamp without time zone NOT NULL DEFAULT now(), - updated_at timestamp without time zone NOT NULL DEFAULT now() + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE public.purchase_allocations ( + purchase_id uuid NOT NULL REFERENCES public.purchases (id), + bucket_id uuid NOT NULL REFERENCES public.ticket_option_buckets (id), + quantity integer NOT NULL CHECK (quantity > 0), + PRIMARY KEY (purchase_id, bucket_id) ); CREATE INDEX purchases_ticket_option_id_idx ON public.purchases (ticket_option_id); +CREATE INDEX ticket_option_buckets_ticket_option_id_idx ON public.ticket_option_buckets (ticket_option_id); diff --git a/go.mod b/go.mod index d12d53c..be7c1b3 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen require ( github.com/google/uuid v1.6.0 + github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.10.0 github.com/jmoiron/sqlx v1.4.0 github.com/oapi-codegen/runtime v1.4.2 diff --git a/go.sum b/go.sum index 94b0ce8..49a60ad 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= diff --git a/internal/api/v1/Ticket_Allocation.swagger.yaml b/internal/api/v1/Ticket_Allocation.swagger.yaml index 7b63b89..b65ae18 100644 --- a/internal/api/v1/Ticket_Allocation.swagger.yaml +++ b/internal/api/v1/Ticket_Allocation.swagger.yaml @@ -146,6 +146,14 @@ components: type: integer minimum: 0 example: 100 + bucket_count: + type: integer + minimum: 1 + maximum: 32 + description: >- + Optional number of capacity buckets for write sharding. + Defaults to 1 when omitted. Must be <= allocation and <= 32. + example: 1 CreatePurchaseRequest: type: object required: diff --git a/internal/api/v1/api.gen.go b/internal/api/v1/api.gen.go index af19172..bdca526 100644 --- a/internal/api/v1/api.gen.go +++ b/internal/api/v1/api.gen.go @@ -34,7 +34,10 @@ type CreatePurchaseRequest struct { type CreateTicketOptionRequest struct { Data struct { Attributes struct { - Allocation int `json:"allocation"` + Allocation int `json:"allocation"` + + // BucketCount Optional number of capacity buckets for write sharding. Defaults to 1 when omitted. Must be <= allocation and <= 32. + BucketCount *int `json:"bucket_count,omitempty"` Description *string `json:"description,omitempty"` Name string `json:"name"` } `json:"attributes"` diff --git a/internal/api/v1/handler.go b/internal/api/v1/handler.go index 5fff8ec..85a9fa8 100644 --- a/internal/api/v1/handler.go +++ b/internal/api/v1/handler.go @@ -37,6 +37,7 @@ func (h *Server) CreateTicketOption(ctx context.Context, request CreateTicketOpt Name: attrs.Name, Description: description, Allocation: attrs.Allocation, + BucketCount: ticketing.ResolveBucketCount(attrs.Allocation, attrs.BucketCount), } if err := params.Validate(); err != nil { var invalid *ticketing.InvalidInputError diff --git a/internal/api/v1/handler_test.go b/internal/api/v1/handler_test.go index 076f023..dc97d90 100644 --- a/internal/api/v1/handler_test.go +++ b/internal/api/v1/handler_test.go @@ -142,6 +142,53 @@ func TestCreateTicketOptionValidation(t *testing.T) { } } +func TestCreateTicketOptionBucketCountValidation(t *testing.T) { + t.Parallel() + h := newTestServer(t) + + status, body := doJSON(t, h, http.MethodPost, "/v1/ticket_options", map[string]any{ + "data": map[string]any{ + "type": "ticket_options", + "attributes": map[string]any{ + "name": "GA", + "allocation": 5, + "bucket_count": 6, + }, + }, + }) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body=%v", status, body) + } + + status, body = doJSON(t, h, http.MethodPost, "/v1/ticket_options", map[string]any{ + "data": map[string]any{ + "type": "ticket_options", + "attributes": map[string]any{ + "name": "GA", + "allocation": 100, + "bucket_count": 33, + }, + }, + }) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for max, body=%v", status, body) + } + + status, body = doJSON(t, h, http.MethodPost, "/v1/ticket_options", map[string]any{ + "data": map[string]any{ + "type": "ticket_options", + "attributes": map[string]any{ + "name": "GA", + "allocation": 100, + "bucket_count": 8, + }, + }, + }) + if status != http.StatusCreated { + t.Fatalf("status = %d, want 201, body=%v", status, body) + } +} + func TestPurchaseSuccessAndOversell(t *testing.T) { t.Parallel() h := newTestServer(t) diff --git a/internal/store/memory/store.go b/internal/store/memory/store.go index 78c0538..56d7c70 100644 --- a/internal/store/memory/store.go +++ b/internal/store/memory/store.go @@ -2,6 +2,7 @@ package memory import ( "context" + "math/rand" "sync" "time" @@ -10,16 +11,28 @@ import ( "ticket-allocation/internal/ticketing" ) -// Store is an in-memory ticketing.Store suitable for unit tests. +type bucket struct { + ID uuid.UUID + Index int + Capacity int + Purchased int +} + +type ticketOption struct { + Option ticketing.TicketOption + Buckets []bucket +} + +// Store is an in-memory ticketing.Store with bucketed capacity. type Store struct { mu sync.Mutex - ticketOptions map[uuid.UUID]ticketing.TicketOption + ticketOptions map[uuid.UUID]*ticketOption purchases map[uuid.UUID]ticketing.Purchase } func NewStore() *Store { return &Store{ - ticketOptions: make(map[uuid.UUID]ticketing.TicketOption), + ticketOptions: make(map[uuid.UUID]*ticketOption), purchases: make(map[uuid.UUID]ticketing.Purchase), } } @@ -29,6 +42,14 @@ func (s *Store) CreateTicketOption(_ context.Context, params ticketing.CreateTic return ticketing.TicketOption{}, err } + capacities, err := ticketing.SplitAllocation(params.Allocation, params.BucketCount) + if err != nil { + return ticketing.TicketOption{}, &ticketing.InvalidInputError{ + Pointer: "/data/attributes/bucket_count", + Detail: err.Error(), + } + } + s.mu.Lock() defer s.mu.Unlock() @@ -38,11 +59,24 @@ func (s *Store) CreateTicketOption(_ context.Context, params ticketing.CreateTic Name: params.Name, Description: params.Description, Allocation: params.Allocation, - Purchased: 0, + BucketCount: params.BucketCount, CreatedAt: now, UpdatedAt: now, } - s.ticketOptions[option.ID] = option + + buckets := make([]bucket, len(capacities)) + for i, capacity := range capacities { + buckets[i] = bucket{ + ID: uuid.New(), + Index: i, + Capacity: capacity, + } + } + + s.ticketOptions[option.ID] = &ticketOption{ + Option: option, + Buckets: buckets, + } return option, nil } @@ -50,11 +84,11 @@ func (s *Store) GetTicketOption(_ context.Context, id uuid.UUID) (ticketing.Tick s.mu.Lock() defer s.mu.Unlock() - option, ok := s.ticketOptions[id] + to, ok := s.ticketOptions[id] if !ok { return ticketing.TicketOption{}, ticketing.ErrTicketOptionNotFound } - return option, nil + return to.Option, nil } func (s *Store) CreatePurchase(_ context.Context, params ticketing.CreatePurchaseParams) (ticketing.Purchase, error) { @@ -65,18 +99,22 @@ func (s *Store) CreatePurchase(_ context.Context, params ticketing.CreatePurchas s.mu.Lock() defer s.mu.Unlock() - option, ok := s.ticketOptions[params.TicketOptionID] + to, ok := s.ticketOptions[params.TicketOptionID] if !ok { return ticketing.Purchase{}, ticketing.ErrTicketOptionNotFound } - if option.Purchased+params.Quantity > option.Allocation { + if len(to.Buckets) == 0 { return ticketing.Purchase{}, ticketing.ErrInsufficientAllocation } + if !tryReserveSingleBucketMem(to.Buckets, params.Quantity) { + if !reserveMultiBucketMem(to.Buckets, params.Quantity) { + return ticketing.Purchase{}, ticketing.ErrInsufficientAllocation + } + } + now := time.Now().UTC() - option.Purchased += params.Quantity - option.UpdatedAt = now - s.ticketOptions[option.ID] = option + to.Option.UpdatedAt = now purchase := ticketing.Purchase{ ID: uuid.New(), @@ -90,14 +128,80 @@ func (s *Store) CreatePurchase(_ context.Context, params ticketing.CreatePurchas return purchase, nil } -// PurchasedSum returns the sum of purchase quantities for a ticket option (test helper). +// PurchasedSum returns the sum of purchased quantities for a ticket option (test helper). func (s *Store) PurchasedSum(ticketOptionID uuid.UUID) int { s.mu.Lock() defer s.mu.Unlock() - option, ok := s.ticketOptions[ticketOptionID] + to, ok := s.ticketOptions[ticketOptionID] if !ok { return 0 } - return option.Purchased + return purchasedSum(to.Buckets) +} + +// BucketPurchased returns purchased counts by bucket index (test helper). +func (s *Store) BucketPurchased(ticketOptionID uuid.UUID) []int { + s.mu.Lock() + defer s.mu.Unlock() + + to, ok := s.ticketOptions[ticketOptionID] + if !ok { + return nil + } + out := make([]int, len(to.Buckets)) + for i, b := range to.Buckets { + out[i] = b.Purchased + } + return out +} + +func purchasedSum(buckets []bucket) int { + sum := 0 + for _, b := range buckets { + sum += b.Purchased + } + return sum +} + +func tryReserveSingleBucketMem(buckets []bucket, qty int) bool { + n := len(buckets) + start := rand.Intn(n) + for i := 0; i < n; i++ { + idx := (start + i) % n + if buckets[idx].Purchased+qty <= buckets[idx].Capacity { + buckets[idx].Purchased += qty + return true + } + } + return false +} + +func reserveMultiBucketMem(buckets []bucket, qty int) bool { + remaining := qty + takes := make([]int, len(buckets)) + for i := range buckets { + if remaining == 0 { + break + } + available := buckets[i].Capacity - buckets[i].Purchased + if available <= 0 { + continue + } + take := available + if take > remaining { + take = remaining + } + takes[i] = take + remaining -= take + } + if remaining > 0 { + return false + } + for i, take := range takes { + if take > 0 { + buckets[i].Purchased += take + } + } + return true } diff --git a/internal/store/memory/store_test.go b/internal/store/memory/store_test.go index 15fa562..b0ea2eb 100644 --- a/internal/store/memory/store_test.go +++ b/internal/store/memory/store_test.go @@ -20,8 +20,9 @@ func TestCreatePurchaseRespectsAllocation(t *testing.T) { ctx := context.Background() option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ - Name: "GA", - Allocation: 5, + Name: "GA", + Allocation: 5, + BucketCount: 1, }) if err != nil { t.Fatalf("create option: %v", err) @@ -46,18 +47,69 @@ func TestCreatePurchaseRespectsAllocation(t *testing.T) { t.Fatalf("expected ErrInsufficientAllocation, got %v", err) } + if got := store.PurchasedSum(option.ID); got != 3 { + t.Fatalf("purchased = %d, want 3", got) + } got, err := store.GetTicketOption(ctx, option.ID) if err != nil { t.Fatalf("get option: %v", err) } - if got.Purchased != 3 { - t.Fatalf("purchased = %d, want 3", got.Purchased) - } if got.Allocation != 5 { t.Fatalf("allocation = %d, want 5", got.Allocation) } } +func TestMultiBucketFragmentation(t *testing.T) { + t.Parallel() + + store := memory.NewStore() + ctx := context.Background() + + // 3 buckets of capacity 2 each (allocation 6). + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: 6, + BucketCount: 3, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + userID := uuid.New() + // Buy 1 from each bucket to fragment remainders to [1,1,1]. + for i := 0; i < 3; i++ { + if _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: userID, + TicketOptionID: option.ID, + }); err != nil { + t.Fatalf("seed buy %d: %v", i, err) + } + } + + // Buy 3 must span buckets. + if _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 3, + UserID: userID, + TicketOptionID: option.ID, + }); err != nil { + t.Fatalf("multi-bucket buy: %v", err) + } + + if got := store.PurchasedSum(option.ID); got != 6 { + t.Fatalf("purchased = %d, want 6", got) + } + + _, err = store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: userID, + TicketOptionID: option.ID, + }) + if !errors.Is(err, ticketing.ErrInsufficientAllocation) { + t.Fatalf("expected insufficient, got %v", err) + } +} + func TestConcurrentPurchasesDoNotOversell(t *testing.T) { t.Parallel() @@ -69,8 +121,9 @@ func TestConcurrentPurchasesDoNotOversell(t *testing.T) { const qty = 3 option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ - Name: "GA", - Allocation: allocation, + Name: "GA", + Allocation: allocation, + BucketCount: 32, }) if err != nil { t.Fatalf("create option: %v", err) diff --git a/internal/store/postgres/store.go b/internal/store/postgres/store.go index b1f1080..c5986e2 100644 --- a/internal/store/postgres/store.go +++ b/internal/store/postgres/store.go @@ -5,15 +5,18 @@ import ( "database/sql" "errors" "fmt" + "math/rand" "time" "github.com/google/uuid" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5/pgconn" "github.com/jmoiron/sqlx" "ticket-allocation/internal/ticketing" ) -// Store is a Postgres-backed ticketing.Store. +// Store is a Postgres-backed ticketing.Store using bucketed capacity. type Store struct { db *sqlx.DB } @@ -27,7 +30,7 @@ type ticketOptionRow struct { Name string `db:"name"` Description string `db:"description"` Allocation int `db:"allocation"` - Purchased int `db:"purchased"` + BucketCount int `db:"bucket_count"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } @@ -38,12 +41,20 @@ func (r ticketOptionRow) toDomain() ticketing.TicketOption { Name: r.Name, Description: r.Description, Allocation: r.Allocation, - Purchased: r.Purchased, + BucketCount: r.BucketCount, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, } } +type bucketRow struct { + ID uuid.UUID `db:"id"` + TicketOptionID uuid.UUID `db:"ticket_option_id"` + BucketIndex int `db:"bucket_index"` + Capacity int `db:"capacity"` + Purchased int `db:"purchased"` +} + type purchaseRow struct { ID uuid.UUID `db:"id"` Quantity int `db:"quantity"` @@ -69,24 +80,54 @@ func (s *Store) CreateTicketOption(ctx context.Context, params ticketing.CreateT return ticketing.TicketOption{}, err } - const q = ` - INSERT INTO ticket_options (name, description, allocation) - VALUES ($1, $2, $3) - RETURNING id, name, description, allocation, purchased, created_at, updated_at - ` + capacities, err := ticketing.SplitAllocation(params.Allocation, params.BucketCount) + if err != nil { + return ticketing.TicketOption{}, &ticketing.InvalidInputError{ + Pointer: "/data/attributes/bucket_count", + Detail: err.Error(), + } + } - var row ticketOptionRow - if err := s.db.GetContext(ctx, &row, q, params.Name, params.Description, params.Allocation); err != nil { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return ticketing.TicketOption{}, fmt.Errorf("begin create ticket option tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + const insertOption = ` + INSERT INTO ticket_options (name, description, allocation, bucket_count) + VALUES ($1, $2, $3, $4) + RETURNING id, name, description, allocation, bucket_count, created_at, updated_at + ` + var option ticketOptionRow + if err := tx.GetContext(ctx, &option, insertOption, params.Name, params.Description, params.Allocation, params.BucketCount); err != nil { return ticketing.TicketOption{}, fmt.Errorf("create ticket option: %w", err) } - return row.toDomain(), nil + + for i, capacity := range capacities { + const insertBucket = ` + INSERT INTO ticket_option_buckets (ticket_option_id, bucket_index, capacity) + VALUES ($1, $2, $3) + ` + if _, err := tx.ExecContext(ctx, insertBucket, option.ID, i, capacity); err != nil { + return ticketing.TicketOption{}, fmt.Errorf("create ticket option bucket: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return ticketing.TicketOption{}, fmt.Errorf("commit create ticket option: %w", err) + } + + return option.toDomain(), nil } func (s *Store) GetTicketOption(ctx context.Context, id uuid.UUID) (ticketing.TicketOption, error) { const q = ` - SELECT id, name, description, allocation, purchased, created_at, updated_at - FROM ticket_options - WHERE id = $1 + SELECT + t.id, t.name, t.description, t.allocation, t.bucket_count, + t.created_at, t.updated_at + FROM ticket_options t + WHERE t.id = $1 ` var row ticketOptionRow @@ -105,30 +146,58 @@ func (s *Store) CreatePurchase(ctx context.Context, params ticketing.CreatePurch return ticketing.Purchase{}, err } + const maxAttempts = 5 + var lastErr error + for attempt := 0; attempt < maxAttempts; attempt++ { + if err := ctx.Err(); err != nil { + return ticketing.Purchase{}, err + } + purchase, err := s.createPurchaseOnce(ctx, params) + if err == nil { + return purchase, nil + } + if !isRetryableTxError(err) { + return ticketing.Purchase{}, err + } + lastErr = err + // Jittered backoff so concurrent retries do not immediately collide again. + backoff := time.Duration(5*(1< remaining { + take = remaining + } + result, err := tx.ExecContext(ctx, ` + UPDATE ticket_option_buckets + SET purchased = purchased + $2, + updated_at = now() + WHERE id = $1 + AND purchased + $2 <= capacity + `, b.ID, take) + if err != nil { + return nil, fmt.Errorf("reserve multi bucket: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("reserve multi bucket rows affected: %w", err) + } + if affected != 1 { + return nil, ticketing.ErrInsufficientAllocation + } + allocations = append(allocations, bucketAllocation{BucketID: b.ID, Quantity: take}) + remaining -= take + } + + if remaining > 0 { + return nil, ticketing.ErrInsufficientAllocation + } + return allocations, nil } diff --git a/internal/store/postgres/store_bench_test.go b/internal/store/postgres/store_bench_test.go new file mode 100644 index 0000000..ef7ca4a --- /dev/null +++ b/internal/store/postgres/store_bench_test.go @@ -0,0 +1,102 @@ +//go:build integration + +package postgres_test + +import ( + "context" + "errors" + "fmt" + "os" + "runtime" + "testing" + "time" + + "github.com/google/uuid" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jmoiron/sqlx" + + "ticket-allocation/internal/store/postgres" + "ticket-allocation/internal/ticketing" +) + +const concurrentBuyers = 64 + +const benchAllocation = 5_000_000 + +func openBenchDB(b *testing.B) *sqlx.DB { + b.Helper() + + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + b.Skip("TEST_DATABASE_URL not set") + } + + db, err := sqlx.Connect("pgx", dsn) + if err != nil { + b.Fatalf("connect: %v", err) + } + // Match buyer concurrency so the pool is not the bottleneck we are measuring. + db.SetMaxOpenConns(concurrentBuyers) + db.SetMaxIdleConns(concurrentBuyers) + db.SetConnMaxLifetime(5 * time.Minute) + b.Cleanup(func() { _ = db.Close() }) + return db +} + +// BenchmarkCreatePurchaseContention compares bucket_count=1 vs 32 under a fixed +// parallel purchase load. +func BenchmarkCreatePurchaseContention(b *testing.B) { + for _, bucketCount := range []int{1, 32} { + b.Run(fmt.Sprintf("bucket_count=%d/buyers=%d", bucketCount, concurrentBuyers), func(b *testing.B) { + db := openBenchDB(b) + if _, err := db.Exec(`TRUNCATE purchase_allocations, purchases, ticket_option_buckets, ticket_options CASCADE`); err != nil { + b.Fatalf("truncate: %v", err) + } + + store := postgres.NewStore(db) + ctx := context.Background() + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: fmt.Sprintf("bench-%d", bucketCount), + Allocation: benchAllocation, + BucketCount: bucketCount, + }) + if err != nil { + b.Fatalf("create option: %v", err) + } + + // RunParallel uses parallelism * GOMAXPROCS workers; scale to ~concurrentBuyers. + procs := runtime.GOMAXPROCS(0) + parallelism := (concurrentBuyers + procs - 1) / procs + if parallelism < 1 { + parallelism = 1 + } + b.SetParallelism(parallelism) + + b.ReportMetric(float64(concurrentBuyers), "buyers") + b.ReportMetric(float64(bucketCount), "buckets") + b.ResetTimer() + + b.RunParallel(func(pb *testing.PB) { + userID := uuid.New() + for pb.Next() { + _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: userID, + TicketOptionID: option.ID, + }) + if err == nil { + continue + } + // Sell-out should not happen with benchAllocation; treat as hard failure. + if errors.Is(err, ticketing.ErrInsufficientAllocation) { + b.Error("sold out during benchmark; increase benchAllocation") + return + } + b.Errorf("purchase: %v", err) + return + } + }) + }) + } +} diff --git a/internal/store/postgres/store_integration_test.go b/internal/store/postgres/store_integration_test.go index d5f615e..226f807 100644 --- a/internal/store/postgres/store_integration_test.go +++ b/internal/store/postgres/store_integration_test.go @@ -32,7 +32,6 @@ func openTestDB(t *testing.T) *sqlx.DB { if err != nil { t.Fatalf("connect: %v", err) } - // Cap the pool so concurrent hammers queue instead of exhausting Postgres max_connections. db.SetMaxOpenConns(20) db.SetMaxIdleConns(20) db.SetConnMaxLifetime(5 * time.Minute) @@ -42,11 +41,48 @@ func openTestDB(t *testing.T) *sqlx.DB { func truncate(t *testing.T, db *sqlx.DB) { t.Helper() - if _, err := db.Exec(`TRUNCATE purchases, ticket_options CASCADE`); err != nil { + if _, err := db.Exec(`TRUNCATE purchase_allocations, purchases, ticket_option_buckets, ticket_options CASCADE`); err != nil { t.Fatalf("truncate: %v", err) } } +func assertPurchasedMatchesSum(t *testing.T, db *sqlx.DB, ticketOptionID uuid.UUID, want int) { + t.Helper() + + var purchased int + if err := db.Get(&purchased, ` + SELECT COALESCE(SUM(purchased), 0) FROM ticket_option_buckets WHERE ticket_option_id = $1 + `, ticketOptionID); err != nil { + t.Fatalf("select bucket purchased: %v", err) + } + if purchased != want { + t.Fatalf("bucket purchased sum = %d, want %d", purchased, want) + } + + var sum sql.NullInt64 + if err := db.Get(&sum, ` + SELECT COALESCE(SUM(quantity), 0) FROM purchases WHERE ticket_option_id = $1 + `, ticketOptionID); err != nil { + t.Fatalf("sum purchase quantities: %v", err) + } + if sum.Int64 != int64(want) { + t.Fatalf("sum(purchases.quantity) = %d, want %d", sum.Int64, want) + } + + var allocSum sql.NullInt64 + if err := db.Get(&allocSum, ` + SELECT COALESCE(SUM(pa.quantity), 0) + FROM purchase_allocations pa + JOIN purchases p ON p.id = pa.purchase_id + WHERE p.ticket_option_id = $1 + `, ticketOptionID); err != nil { + t.Fatalf("sum allocation quantities: %v", err) + } + if allocSum.Int64 != int64(want) { + t.Fatalf("sum(purchase_allocations.quantity) = %d, want %d", allocSum.Int64, want) + } +} + func TestPostgresCreateGetAndPurchase(t *testing.T) { db := openTestDB(t) truncate(t, db) @@ -57,6 +93,7 @@ func TestPostgresCreateGetAndPurchase(t *testing.T) { Name: "GA", Description: "general admission", Allocation: 10, + BucketCount: 1, }) if err != nil { t.Fatalf("create: %v", err) @@ -66,7 +103,7 @@ func TestPostgresCreateGetAndPurchase(t *testing.T) { if err != nil { t.Fatalf("get: %v", err) } - if got.Allocation != 10 || got.Name != "GA" { + if got.Allocation != 10 || got.Name != "GA" || got.BucketCount != 1 { t.Fatalf("unexpected option: %+v", got) } @@ -86,12 +123,41 @@ func TestPostgresCreateGetAndPurchase(t *testing.T) { if err != nil { t.Fatalf("get after purchase: %v", err) } - if got.Purchased != 4 { - t.Fatalf("purchased = %d, want 4", got.Purchased) - } if got.Allocation != 10 { t.Fatalf("allocation mutated: %d", got.Allocation) } + assertPurchasedMatchesSum(t, db, option.ID, 4) +} + +func TestPostgresCreatesBuckets(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + + option, err := store.CreateTicketOption(context.Background(), ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: 100, + BucketCount: 32, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + var count int + if err := db.Get(&count, `SELECT COUNT(*) FROM ticket_option_buckets WHERE ticket_option_id = $1`, option.ID); err != nil { + t.Fatalf("count buckets: %v", err) + } + if count != 32 { + t.Fatalf("bucket count = %d, want 32", count) + } + + var capacitySum int + if err := db.Get(&capacitySum, `SELECT SUM(capacity) FROM ticket_option_buckets WHERE ticket_option_id = $1`, option.ID); err != nil { + t.Fatalf("sum capacity: %v", err) + } + if capacitySum != 100 { + t.Fatalf("capacity sum = %d, want 100", capacitySum) + } } func TestPostgresInsufficientAllocation(t *testing.T) { @@ -101,8 +167,9 @@ func TestPostgresInsufficientAllocation(t *testing.T) { ctx := context.Background() option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ - Name: "GA", - Allocation: 2, + Name: "GA", + Allocation: 2, + BucketCount: 1, }) if err != nil { t.Fatalf("create: %v", err) @@ -117,13 +184,7 @@ func TestPostgresInsufficientAllocation(t *testing.T) { t.Fatalf("expected insufficient allocation, got %v", err) } - var purchased int - if err := db.Get(&purchased, `SELECT purchased FROM ticket_options WHERE id = $1`, option.ID); err != nil { - t.Fatalf("select purchased: %v", err) - } - if purchased != 0 { - t.Fatalf("purchased = %d, want 0", purchased) - } + assertPurchasedMatchesSum(t, db, option.ID, 0) var count int if err := db.Get(&count, `SELECT COUNT(*) FROM purchases WHERE ticket_option_id = $1`, option.ID); err != nil { @@ -149,6 +210,55 @@ func TestPostgresPurchaseMissingOption(t *testing.T) { } } +func TestPostgresMultiBucketFragmentation(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + ctx := context.Background() + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: 6, + BucketCount: 3, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + userID := uuid.New() + for i := 0; i < 3; i++ { + if _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: userID, + TicketOptionID: option.ID, + }); err != nil { + t.Fatalf("seed buy %d: %v", i, err) + } + } + + if _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 3, + UserID: userID, + TicketOptionID: option.ID, + }); err != nil { + t.Fatalf("multi-bucket buy: %v", err) + } + + assertPurchasedMatchesSum(t, db, option.ID, 6) + + var allocLines int + if err := db.Get(&allocLines, ` + SELECT COUNT(*) FROM purchase_allocations pa + JOIN purchases p ON p.id = pa.purchase_id + WHERE p.ticket_option_id = $1 + `, option.ID); err != nil { + t.Fatalf("count allocation lines: %v", err) + } + if allocLines < 4 { + t.Fatalf("allocation lines = %d, want at least 4 (3 singles + multi)", allocLines) + } +} + func TestPostgresConcurrentPurchasesDoNotOversell(t *testing.T) { db := openTestDB(t) truncate(t, db) @@ -160,8 +270,9 @@ func TestPostgresConcurrentPurchasesDoNotOversell(t *testing.T) { const qty = 3 option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ - Name: "GA", - Allocation: allocation, + Name: "GA", + Allocation: allocation, + BucketCount: 32, }) if err != nil { t.Fatalf("create: %v", err) @@ -186,7 +297,6 @@ func TestPostgresConcurrentPurchasesDoNotOversell(t *testing.T) { case err == nil: successes.Add(1) case errors.Is(err, ticketing.ErrInsufficientAllocation): - // expected under contention default: t.Errorf("unexpected error: %v", err) } @@ -198,62 +308,7 @@ func TestPostgresConcurrentPurchasesDoNotOversell(t *testing.T) { if successes.Load() != wantSuccess { t.Fatalf("successes = %d, want %d", successes.Load(), wantSuccess) } - - var purchased int - if err := db.Get(&purchased, `SELECT purchased FROM ticket_options WHERE id = $1`, option.ID); err != nil { - t.Fatalf("select purchased: %v", err) - } - if purchased != int(wantSuccess*qty) { - t.Fatalf("purchased counter = %d, want %d", purchased, wantSuccess*qty) - } - - var sum sql.NullInt64 - if err := db.Get(&sum, `SELECT COALESCE(SUM(quantity), 0) FROM purchases WHERE ticket_option_id = $1`, option.ID); err != nil { - t.Fatalf("sum quantities: %v", err) - } - if sum.Int64 != wantSuccess*qty { - t.Fatalf("sum(quantity) = %d, want %d", sum.Int64, wantSuccess*qty) - } - if sum.Int64 > allocation { - t.Fatalf("oversold: sum=%d allocation=%d", sum.Int64, allocation) - } -} - -func TestPostgresExactSellOut(t *testing.T) { - db := openTestDB(t) - truncate(t, db) - store := postgres.NewStore(db) - ctx := context.Background() - - option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ - Name: "GA", - Allocation: 10, - }) - if err != nil { - t.Fatalf("create: %v", err) - } - - userID := uuid.New() - for _, qty := range []int{7, 3} { - if _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ - Quantity: qty, - UserID: userID, - TicketOptionID: option.ID, - }); err != nil { - t.Fatalf("purchase qty=%d: %v", qty, err) - } - } - - _, err = store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ - Quantity: 1, - UserID: userID, - TicketOptionID: option.ID, - }) - if !errors.Is(err, ticketing.ErrInsufficientAllocation) { - t.Fatalf("expected insufficient allocation after sell-out, got %v", err) - } - - assertPurchasedMatchesSum(t, db, option.ID, 10) + assertPurchasedMatchesSum(t, db, option.ID, int(wantSuccess*qty)) } func TestPostgresConcurrentExactBoundary(t *testing.T) { @@ -266,8 +321,9 @@ func TestPostgresConcurrentExactBoundary(t *testing.T) { const workers = 50 option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ - Name: "GA", - Allocation: allocation, + Name: "GA", + Allocation: allocation, + BucketCount: 16, }) if err != nil { t.Fatalf("create: %v", err) @@ -292,7 +348,6 @@ func TestPostgresConcurrentExactBoundary(t *testing.T) { case err == nil: successes.Add(1) case errors.Is(err, ticketing.ErrInsufficientAllocation): - // should not happen when workers == allocation and qty == 1 t.Errorf("unexpected insufficient allocation with exact boundary") default: t.Errorf("unexpected error: %v", err) @@ -305,6 +360,55 @@ func TestPostgresConcurrentExactBoundary(t *testing.T) { t.Fatalf("successes = %d, want %d", successes.Load(), allocation) } assertPurchasedMatchesSum(t, db, option.ID, allocation) + + var touchedBuckets int + if err := db.Get(&touchedBuckets, ` + SELECT COUNT(*) FROM ticket_option_buckets + WHERE ticket_option_id = $1 AND purchased > 0 + `, option.ID); err != nil { + t.Fatalf("count touched buckets: %v", err) + } + if touchedBuckets < 2 { + t.Fatalf("touched buckets = %d, want >= 2 under concurrent load", touchedBuckets) + } +} + +func TestPostgresExactSellOut(t *testing.T) { + db := openTestDB(t) + truncate(t, db) + store := postgres.NewStore(db) + ctx := context.Background() + + option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + Name: "GA", + Allocation: 10, + BucketCount: 1, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + userID := uuid.New() + for _, qty := range []int{7, 3} { + if _, err := store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: qty, + UserID: userID, + TicketOptionID: option.ID, + }); err != nil { + t.Fatalf("purchase qty=%d: %v", qty, err) + } + } + + _, err = store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ + Quantity: 1, + UserID: userID, + TicketOptionID: option.ID, + }) + if !errors.Is(err, ticketing.ErrInsufficientAllocation) { + t.Fatalf("expected insufficient allocation after sell-out, got %v", err) + } + + assertPurchasedMatchesSum(t, db, option.ID, 10) } func TestPostgresIndependentOptionsUnderContention(t *testing.T) { @@ -322,15 +426,17 @@ func TestPostgresIndependentOptionsUnderContention(t *testing.T) { ) optionA, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ - Name: "A", - Allocation: allocationA, + Name: "A", + Allocation: allocationA, + BucketCount: 8, }) if err != nil { t.Fatalf("create A: %v", err) } optionB, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ - Name: "B", - Allocation: allocationB, + Name: "B", + Allocation: allocationB, + BucketCount: 8, }) if err != nil { t.Fatalf("create B: %v", err) @@ -389,19 +495,6 @@ func TestPostgresIndependentOptionsUnderContention(t *testing.T) { assertPurchasedMatchesSum(t, db, optionA.ID, allocationA) assertPurchasedMatchesSum(t, db, optionB.ID, allocationB) - - // No cross-contamination: every purchase row must reference exactly one of the two options, - // and counts must match the per-option counters. - var foreignOrphan int - if err := db.Get(&foreignOrphan, ` - SELECT COUNT(*) FROM purchases - WHERE ticket_option_id NOT IN ($1, $2) - `, optionA.ID, optionB.ID); err != nil { - t.Fatalf("count orphan FKs: %v", err) - } - if foreignOrphan != 0 { - t.Fatalf("found %d purchases with unexpected ticket_option_id", foreignOrphan) - } } func TestPostgresZeroAllocation(t *testing.T) { @@ -411,8 +504,9 @@ func TestPostgresZeroAllocation(t *testing.T) { ctx := context.Background() option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ - Name: "SoldOut", - Allocation: 0, + Name: "SoldOut", + Allocation: 0, + BucketCount: 0, }) if err != nil { t.Fatalf("create: %v", err) @@ -428,19 +522,8 @@ func TestPostgresZeroAllocation(t *testing.T) { } assertPurchasedMatchesSum(t, db, option.ID, 0) - - var count int - if err := db.Get(&count, `SELECT COUNT(*) FROM purchases WHERE ticket_option_id = $1`, option.ID); err != nil { - t.Fatalf("count purchases: %v", err) - } - if count != 0 { - t.Fatalf("purchase rows = %d, want 0", count) - } } -// TestPostgresCancelUnderLoad cancels an in-flight purchase storm and asserts the -// purchased counter never diverges from SUM(purchases.quantity) — i.e. cancelled -// transactions roll back both the counter bump and the purchase insert together. func TestPostgresCancelUnderLoad(t *testing.T) { db := openTestDB(t) truncate(t, db) @@ -450,8 +533,9 @@ func TestPostgresCancelUnderLoad(t *testing.T) { const workers = 100 option, err := store.CreateTicketOption(context.Background(), ticketing.CreateTicketOptionParams{ - Name: "GA", - Allocation: allocation, + Name: "GA", + Allocation: allocation, + BucketCount: 16, }) if err != nil { t.Fatalf("create: %v", err) @@ -485,9 +569,7 @@ func TestPostgresCancelUnderLoad(t *testing.T) { case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded): cancels.Add(1) case errors.Is(err, ticketing.ErrInsufficientAllocation): - // possible if cancel is late and capacity is exhausted case ctx.Err() != nil: - // Driver may surface sql.ErrTxDone / "already rolled back" after cancel. cancels.Add(1) default: otherErrs.Add(1) @@ -497,7 +579,6 @@ func TestPostgresCancelUnderLoad(t *testing.T) { } close(gate) - // Let some transactions begin acquiring connections / row locks, then cancel. time.Sleep(5 * time.Millisecond) cancel() wg.Wait() @@ -510,7 +591,9 @@ func TestPostgresCancelUnderLoad(t *testing.T) { } var purchased int - if err := db.Get(&purchased, `SELECT purchased FROM ticket_options WHERE id = $1`, option.ID); err != nil { + if err := db.Get(&purchased, ` + SELECT COALESCE(SUM(purchased), 0) FROM ticket_option_buckets WHERE ticket_option_id = $1 + `, option.ID); err != nil { t.Fatalf("select purchased: %v", err) } var sum sql.NullInt64 @@ -524,27 +607,8 @@ func TestPostgresCancelUnderLoad(t *testing.T) { if purchased > allocation { t.Fatalf("oversold after cancel: purchased=%d allocation=%d", purchased, allocation) } - if int64(purchased) != successes.Load() { - t.Fatalf("purchased=%d does not match observed successes=%d", purchased, successes.Load()) - } -} - -func assertPurchasedMatchesSum(t *testing.T, db *sqlx.DB, ticketOptionID uuid.UUID, want int) { - t.Helper() - - var purchased int - if err := db.Get(&purchased, `SELECT purchased FROM ticket_options WHERE id = $1`, ticketOptionID); err != nil { - t.Fatalf("select purchased: %v", err) - } - if purchased != want { - t.Fatalf("purchased = %d, want %d", purchased, want) - } - - var sum sql.NullInt64 - if err := db.Get(&sum, `SELECT COALESCE(SUM(quantity), 0) FROM purchases WHERE ticket_option_id = $1`, ticketOptionID); err != nil { - t.Fatalf("sum quantities: %v", err) - } - if sum.Int64 != int64(want) { - t.Fatalf("sum(quantity) = %d, want %d", sum.Int64, want) + // Counters must still match the ledger; observed successes may under-count committed rows. + if int64(purchased) > successes.Load()+cancels.Load() { + t.Fatalf("purchased=%d exceeds successes(%d)+cancels(%d)", purchased, successes.Load(), cancels.Load()) } } diff --git a/internal/ticketing/ticketing.go b/internal/ticketing/ticketing.go index 88a8c8e..a4af05a 100644 --- a/internal/ticketing/ticketing.go +++ b/internal/ticketing/ticketing.go @@ -3,12 +3,15 @@ package ticketing import ( "context" "errors" + "fmt" "strings" "time" "github.com/google/uuid" ) +const MaxBucketCount = 32 + var ( ErrTicketOptionNotFound = errors.New("ticket option not found") ErrInsufficientAllocation = errors.New("insufficient allocation") @@ -37,7 +40,7 @@ type TicketOption struct { Name string Description string Allocation int - Purchased int + BucketCount int CreatedAt time.Time UpdatedAt time.Time } @@ -55,6 +58,7 @@ type CreateTicketOptionParams struct { Name string Description string Allocation int + BucketCount int // resolved create-time value (default 1 when allocation > 0) } func (p CreateTicketOptionParams) Validate() error { @@ -70,9 +74,85 @@ func (p CreateTicketOptionParams) Validate() error { Detail: "allocation must be a non-negative integer", } } + if err := ValidateBucketCount(p.Allocation, p.BucketCount); err != nil { + return err + } + return nil +} + +// ValidateBucketCount enforces bucket_count rules at create time. +func ValidateBucketCount(allocation, bucketCount int) error { + if allocation == 0 { + if bucketCount != 0 { + return &InvalidInputError{ + Pointer: "/data/attributes/bucket_count", + Detail: "bucket_count must be 0 when allocation is 0", + } + } + return nil + } + if bucketCount < 1 { + return &InvalidInputError{ + Pointer: "/data/attributes/bucket_count", + Detail: "bucket_count must be at least 1", + } + } + if bucketCount > MaxBucketCount { + return &InvalidInputError{ + Pointer: "/data/attributes/bucket_count", + Detail: fmt.Sprintf("bucket_count must be at most %d", MaxBucketCount), + } + } + if bucketCount > allocation { + return &InvalidInputError{ + Pointer: "/data/attributes/bucket_count", + Detail: "bucket_count must be less than or equal to allocation", + } + } return nil } +// ResolveBucketCount applies the create-time default +func ResolveBucketCount(allocation int, requested *int) int { + if allocation == 0 { + return 0 + } + if requested == nil { + return 1 + } + return *requested +} + +// SplitAllocation evenly divides total capacity across bucketCount buckets. +func SplitAllocation(total, bucketCount int) ([]int, error) { + if total < 0 { + return nil, fmt.Errorf("total must be non-negative") + } + if total == 0 { + if bucketCount != 0 { + return nil, fmt.Errorf("bucketCount must be 0 when total is 0") + } + return nil, nil + } + if bucketCount < 1 { + return nil, fmt.Errorf("bucketCount must be at least 1") + } + if bucketCount > total { + return nil, fmt.Errorf("bucketCount must be <= total") + } + + base := total / bucketCount + rem := total % bucketCount + out := make([]int, bucketCount) + for i := 0; i < bucketCount; i++ { + out[i] = base + if i < rem { + out[i]++ + } + } + return out, nil +} + type CreatePurchaseParams struct { Quantity int UserID uuid.UUID diff --git a/internal/ticketing/ticketing_test.go b/internal/ticketing/ticketing_test.go index 55b1668..7810758 100644 --- a/internal/ticketing/ticketing_test.go +++ b/internal/ticketing/ticketing_test.go @@ -19,27 +19,50 @@ func TestCreateTicketOptionParamsValidate(t *testing.T) { pointer string }{ { - name: "valid", - params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 10}, + name: "valid default bucket", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 10, BucketCount: 1}, + wantErr: false, + }, + { + name: "valid max buckets", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 100, BucketCount: 32}, wantErr: false, }, { name: "missing name", - params: ticketing.CreateTicketOptionParams{Name: " ", Allocation: 10}, + params: ticketing.CreateTicketOptionParams{Name: " ", Allocation: 10, BucketCount: 1}, wantErr: true, pointer: "/data/attributes/name", }, { name: "negative allocation", - params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: -1}, + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: -1, BucketCount: 1}, wantErr: true, pointer: "/data/attributes/allocation", }, { name: "zero allocation allowed", - params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 0}, + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 0, BucketCount: 0}, wantErr: false, }, + { + name: "zero allocation rejects bucket_count", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 0, BucketCount: 1}, + wantErr: true, + pointer: "/data/attributes/bucket_count", + }, + { + name: "bucket_count exceeds max", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 100, BucketCount: 33}, + wantErr: true, + pointer: "/data/attributes/bucket_count", + }, + { + name: "bucket_count exceeds allocation", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 5, BucketCount: 6}, + wantErr: true, + pointer: "/data/attributes/bucket_count", + }, } for _, tt := range tests { @@ -66,6 +89,78 @@ func TestCreateTicketOptionParamsValidate(t *testing.T) { } } +func TestResolveBucketCount(t *testing.T) { + t.Parallel() + + if got := ticketing.ResolveBucketCount(100, nil); got != 1 { + t.Fatalf("omitted = %d, want 1", got) + } + n := 8 + if got := ticketing.ResolveBucketCount(100, &n); got != 8 { + t.Fatalf("explicit = %d, want 8", got) + } + if got := ticketing.ResolveBucketCount(0, nil); got != 0 { + t.Fatalf("zero allocation omitted = %d, want 0", got) + } +} + +func TestSplitAllocation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + total int + bucketCount int + want []int + wantErr bool + }{ + {name: "zero", total: 0, bucketCount: 0, want: nil}, + {name: "single", total: 100, bucketCount: 1, want: []int{100}}, + {name: "even", total: 100, bucketCount: 4, want: []int{25, 25, 25, 25}}, + {name: "remainder", total: 10, bucketCount: 3, want: []int{4, 3, 3}}, + {name: "max", total: 32, bucketCount: 32, want: ones(32)}, + {name: "too many", total: 5, bucketCount: 6, wantErr: true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := ticketing.SplitAllocation(tt.total, tt.bucketCount) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("len = %d, want %d", len(got), len(tt.want)) + } + sum := 0 + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got %v, want %v", got, tt.want) + } + sum += got[i] + } + if tt.total > 0 && sum != tt.total { + t.Fatalf("sum = %d, want %d", sum, tt.total) + } + }) + } +} + +func ones(n int) []int { + out := make([]int, n) + for i := range out { + out[i] = 1 + } + return out +} + func TestCreatePurchaseParamsValidate(t *testing.T) { t.Parallel() From 76a8f6c4b3fb38a137e5b31f72752916fbdcb1f4 Mon Sep 17 00:00:00 2001 From: Morgan Slater Date: Sun, 12 Jul 2026 14:34:52 +0100 Subject: [PATCH 5/5] fix: force an allocation of 1 Co-authored-by: Cursor Grok 4.5 --- README.md | 6 +-- db/init/001_schema.sql | 4 +- .../api/v1/Ticket_Allocation.swagger.yaml | 7 ++-- internal/api/v1/api.gen.go | 2 +- internal/api/v1/handler.go | 2 +- internal/api/v1/handler_test.go | 28 +++++++++++++ .../store/postgres/store_integration_test.go | 24 ++++------- internal/ticketing/ticketing.go | 41 ++++++------------- internal/ticketing/ticketing_test.go | 25 ++++++----- 9 files changed, 73 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 7fe167a..c3c78a2 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ Instead of writing handlers manually, `internal/api/v1/Ticket_Allocation.swagger ### No-oversell invariant (bucketed capacity) -Total capacity (`allocation`) is fixed on `ticket_options`. At create time it is split across `bucket_count` rows in `ticket_option_buckets` (default **1**, max **32**, and `bucket_count <= allocation` so no empty buckets). +Total capacity (`allocation`) is fixed on `ticket_options`. At create time it is split across `bucket_count` rows in `ticket_option_buckets` (default **1**, max **32**, `allocation >= 1`, and `bucket_count <= allocation` so no empty buckets). Each bucket has its own `purchased` counter with: @@ -123,8 +123,8 @@ This spreads write contention across buckets under concurrent load while remaini - User resources are not managed; any UUID is accepted as `user.id` (per brief) - `description` defaults to empty string when omitted -- `bucket_count` defaults to `1` when omitted and `allocation > 0`; must be `0` when `allocation` is `0` -- Allocation of `0` is allowed; purchases against it fail with insufficient allocation +- `bucket_count` defaults to `1` when omitted; must satisfy `1 <= bucket_count <= min(allocation, 32)` +- `allocation` must be at least `1` - JSON:API response schemas live in the OpenAPI spec so codegen owns serialisation shapes ### Trade-offs diff --git a/db/init/001_schema.sql b/db/init/001_schema.sql index 42e8a75..fe6c379 100644 --- a/db/init/001_schema.sql +++ b/db/init/001_schema.sql @@ -7,8 +7,8 @@ CREATE TABLE public.ticket_options ( id uuid DEFAULT public.uuid_generate_v4() PRIMARY KEY, name character varying NOT NULL, description character varying NOT NULL DEFAULT '', - allocation integer NOT NULL CHECK (allocation >= 0), - bucket_count integer NOT NULL DEFAULT 1 CHECK (bucket_count >= 0 AND bucket_count <= 32), + allocation integer NOT NULL CHECK (allocation >= 1), + bucket_count integer NOT NULL DEFAULT 1 CHECK (bucket_count >= 1 AND bucket_count <= 32), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); diff --git a/internal/api/v1/Ticket_Allocation.swagger.yaml b/internal/api/v1/Ticket_Allocation.swagger.yaml index b65ae18..e1c1514 100644 --- a/internal/api/v1/Ticket_Allocation.swagger.yaml +++ b/internal/api/v1/Ticket_Allocation.swagger.yaml @@ -144,7 +144,7 @@ components: example: ticket option description allocation: type: integer - minimum: 0 + minimum: 1 example: 100 bucket_count: type: integer @@ -152,7 +152,8 @@ components: maximum: 32 description: >- Optional number of capacity buckets for write sharding. - Defaults to 1 when omitted. Must be <= allocation and <= 32. + Defaults to 1 when omitted. Must satisfy + 1 <= bucket_count <= min(allocation, 32). example: 1 CreatePurchaseRequest: type: object @@ -220,7 +221,7 @@ components: type: string allocation: type: integer - minimum: 0 + minimum: 1 PurchaseDocument: type: object required: diff --git a/internal/api/v1/api.gen.go b/internal/api/v1/api.gen.go index bdca526..5036fa0 100644 --- a/internal/api/v1/api.gen.go +++ b/internal/api/v1/api.gen.go @@ -36,7 +36,7 @@ type CreateTicketOptionRequest struct { Attributes struct { Allocation int `json:"allocation"` - // BucketCount Optional number of capacity buckets for write sharding. Defaults to 1 when omitted. Must be <= allocation and <= 32. + // BucketCount Optional number of capacity buckets for write sharding. Defaults to 1 when omitted. Must satisfy 1 <= bucket_count <= min(allocation, 32). BucketCount *int `json:"bucket_count,omitempty"` Description *string `json:"description,omitempty"` Name string `json:"name"` diff --git a/internal/api/v1/handler.go b/internal/api/v1/handler.go index 85a9fa8..d19e51a 100644 --- a/internal/api/v1/handler.go +++ b/internal/api/v1/handler.go @@ -37,7 +37,7 @@ func (h *Server) CreateTicketOption(ctx context.Context, request CreateTicketOpt Name: attrs.Name, Description: description, Allocation: attrs.Allocation, - BucketCount: ticketing.ResolveBucketCount(attrs.Allocation, attrs.BucketCount), + BucketCount: ticketing.ResolveBucketCount(attrs.BucketCount), } if err := params.Validate(); err != nil { var invalid *ticketing.InvalidInputError diff --git a/internal/api/v1/handler_test.go b/internal/api/v1/handler_test.go index dc97d90..4f92eef 100644 --- a/internal/api/v1/handler_test.go +++ b/internal/api/v1/handler_test.go @@ -187,6 +187,34 @@ func TestCreateTicketOptionBucketCountValidation(t *testing.T) { if status != http.StatusCreated { t.Fatalf("status = %d, want 201, body=%v", status, body) } + + status, body = doJSON(t, h, http.MethodPost, "/v1/ticket_options", map[string]any{ + "data": map[string]any{ + "type": "ticket_options", + "attributes": map[string]any{ + "name": "Sold out", + "allocation": 0, + "bucket_count": 1, + }, + }, + }) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for allocation=0, body=%v", status, body) + } + + status, body = doJSON(t, h, http.MethodPost, "/v1/ticket_options", map[string]any{ + "data": map[string]any{ + "type": "ticket_options", + "attributes": map[string]any{ + "name": "GA", + "allocation": 10, + "bucket_count": 0, + }, + }, + }) + if status != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 for bucket_count=0, body=%v", status, body) + } } func TestPurchaseSuccessAndOversell(t *testing.T) { diff --git a/internal/store/postgres/store_integration_test.go b/internal/store/postgres/store_integration_test.go index 226f807..314335f 100644 --- a/internal/store/postgres/store_integration_test.go +++ b/internal/store/postgres/store_integration_test.go @@ -497,31 +497,23 @@ func TestPostgresIndependentOptionsUnderContention(t *testing.T) { assertPurchasedMatchesSum(t, db, optionB.ID, allocationB) } -func TestPostgresZeroAllocation(t *testing.T) { +func TestPostgresRejectsZeroAllocation(t *testing.T) { db := openTestDB(t) truncate(t, db) store := postgres.NewStore(db) - ctx := context.Background() - option, err := store.CreateTicketOption(ctx, ticketing.CreateTicketOptionParams{ + _, err := store.CreateTicketOption(context.Background(), ticketing.CreateTicketOptionParams{ Name: "SoldOut", Allocation: 0, - BucketCount: 0, + BucketCount: 1, }) - if err != nil { - t.Fatalf("create: %v", err) + var invalid *ticketing.InvalidInputError + if !errors.As(err, &invalid) { + t.Fatalf("expected InvalidInputError, got %v", err) } - - _, err = store.CreatePurchase(ctx, ticketing.CreatePurchaseParams{ - Quantity: 1, - UserID: uuid.New(), - TicketOptionID: option.ID, - }) - if !errors.Is(err, ticketing.ErrInsufficientAllocation) { - t.Fatalf("expected insufficient allocation, got %v", err) + if invalid.Pointer != "/data/attributes/allocation" { + t.Fatalf("pointer = %q, want allocation", invalid.Pointer) } - - assertPurchasedMatchesSum(t, db, option.ID, 0) } func TestPostgresCancelUnderLoad(t *testing.T) { diff --git a/internal/ticketing/ticketing.go b/internal/ticketing/ticketing.go index a4af05a..d2c1d0e 100644 --- a/internal/ticketing/ticketing.go +++ b/internal/ticketing/ticketing.go @@ -58,7 +58,7 @@ type CreateTicketOptionParams struct { Name string Description string Allocation int - BucketCount int // resolved create-time value (default 1 when allocation > 0) + BucketCount int // resolved create-time value (default 1) } func (p CreateTicketOptionParams) Validate() error { @@ -68,10 +68,10 @@ func (p CreateTicketOptionParams) Validate() error { Detail: "name is required", } } - if p.Allocation < 0 { + if p.Allocation < 1 { return &InvalidInputError{ Pointer: "/data/attributes/allocation", - Detail: "allocation must be a non-negative integer", + Detail: "allocation must be a positive integer", } } if err := ValidateBucketCount(p.Allocation, p.BucketCount); err != nil { @@ -80,17 +80,9 @@ func (p CreateTicketOptionParams) Validate() error { return nil } -// ValidateBucketCount enforces bucket_count rules at create time. +// ValidateBucketCount enforces bucket_count rules at create time: +// 1 <= bucketCount <= min(allocation, MaxBucketCount). func ValidateBucketCount(allocation, bucketCount int) error { - if allocation == 0 { - if bucketCount != 0 { - return &InvalidInputError{ - Pointer: "/data/attributes/bucket_count", - Detail: "bucket_count must be 0 when allocation is 0", - } - } - return nil - } if bucketCount < 1 { return &InvalidInputError{ Pointer: "/data/attributes/bucket_count", @@ -112,27 +104,18 @@ func ValidateBucketCount(allocation, bucketCount int) error { return nil } -// ResolveBucketCount applies the create-time default -func ResolveBucketCount(allocation int, requested *int) int { - if allocation == 0 { - return 0 - } - if requested == nil { - return 1 +// ResolveBucketCount applies the create-time default when bucket_count is omitted. +func ResolveBucketCount(requested *int) int { + if requested != nil { + return *requested } - return *requested + return 1 } // SplitAllocation evenly divides total capacity across bucketCount buckets. func SplitAllocation(total, bucketCount int) ([]int, error) { - if total < 0 { - return nil, fmt.Errorf("total must be non-negative") - } - if total == 0 { - if bucketCount != 0 { - return nil, fmt.Errorf("bucketCount must be 0 when total is 0") - } - return nil, nil + if total < 1 { + return nil, fmt.Errorf("total must be at least 1") } if bucketCount < 1 { return nil, fmt.Errorf("bucketCount must be at least 1") diff --git a/internal/ticketing/ticketing_test.go b/internal/ticketing/ticketing_test.go index 7810758..230a874 100644 --- a/internal/ticketing/ticketing_test.go +++ b/internal/ticketing/ticketing_test.go @@ -41,13 +41,14 @@ func TestCreateTicketOptionParamsValidate(t *testing.T) { pointer: "/data/attributes/allocation", }, { - name: "zero allocation allowed", - params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 0, BucketCount: 0}, - wantErr: false, + name: "zero allocation rejected", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 0, BucketCount: 1}, + wantErr: true, + pointer: "/data/attributes/allocation", }, { - name: "zero allocation rejects bucket_count", - params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 0, BucketCount: 1}, + name: "bucket_count zero rejected", + params: ticketing.CreateTicketOptionParams{Name: "GA", Allocation: 10, BucketCount: 0}, wantErr: true, pointer: "/data/attributes/bucket_count", }, @@ -92,15 +93,16 @@ func TestCreateTicketOptionParamsValidate(t *testing.T) { func TestResolveBucketCount(t *testing.T) { t.Parallel() - if got := ticketing.ResolveBucketCount(100, nil); got != 1 { + if got := ticketing.ResolveBucketCount(nil); got != 1 { t.Fatalf("omitted = %d, want 1", got) } n := 8 - if got := ticketing.ResolveBucketCount(100, &n); got != 8 { + if got := ticketing.ResolveBucketCount(&n); got != 8 { t.Fatalf("explicit = %d, want 8", got) } - if got := ticketing.ResolveBucketCount(0, nil); got != 0 { - t.Fatalf("zero allocation omitted = %d, want 0", got) + zero := 0 + if got := ticketing.ResolveBucketCount(&zero); got != 0 { + t.Fatalf("explicit zero = %d, want 0 (for validation)", got) } } @@ -114,7 +116,8 @@ func TestSplitAllocation(t *testing.T) { want []int wantErr bool }{ - {name: "zero", total: 0, bucketCount: 0, want: nil}, + {name: "zero total", total: 0, bucketCount: 1, wantErr: true}, + {name: "zero buckets", total: 100, bucketCount: 0, wantErr: true}, {name: "single", total: 100, bucketCount: 1, want: []int{100}}, {name: "even", total: 100, bucketCount: 4, want: []int{25, 25, 25, 25}}, {name: "remainder", total: 10, bucketCount: 3, want: []int{4, 3, 3}}, @@ -146,7 +149,7 @@ func TestSplitAllocation(t *testing.T) { } sum += got[i] } - if tt.total > 0 && sum != tt.total { + if sum != tt.total { t.Fatalf("sum = %d, want %d", sum, tt.total) } })