From 54e36528bc94172050d03d4d1b13ca1be416dcdb Mon Sep 17 00:00:00 2001 From: siyahas Date: Tue, 2 Sep 2025 14:44:35 +0300 Subject: [PATCH] Add actor, film, and category APIs with SQL query support - Generated SQL queries for actors, films, categories. - Introduced HTTP handlers for actor, film, and category endpoints. - Included utility functions for parsing query parameters and building URLs. - Enabled pagination, filtering, and HAL representation for responses. --- .gitignore | 33 ++ .gitmodules | 3 + .idea/.gitignore | 8 + .idea/dataSources.xml | 19 + .idea/form-builder-be-go.iml | 9 + .idea/modules.xml | 8 + .idea/sqlDataSources.xml | 28 ++ .idea/sqldialects.xml | 10 + .idea/vcs.xml | 6 + go.mod | 40 ++ go.sum | 97 +++++ internal/hal/hal.go | 88 +++++ internal/httpapi/actors.go | 287 ++++++++++++++ internal/httpapi/categories.go | 103 +++++ internal/httpapi/films.go | 459 ++++++++++++++++++++++ internal/httpapi/languages.go | 106 +++++ internal/httpapi/root.go | 22 ++ internal/httpapi/util.go | 53 +++ internal/sqlc/actor.sql.go | 219 +++++++++++ internal/sqlc/categories.sql.go | 139 +++++++ internal/sqlc/db.go | 31 ++ internal/sqlc/film.sql.go | 158 ++++++++ internal/sqlc/film_filter_language.sql.go | 78 ++++ internal/sqlc/language.sql.go | 70 ++++ internal/sqlc/models.go | 193 +++++++++ main.go | 66 ++++ queries/actor.sql | 55 +++ queries/categories.sql | 31 ++ queries/film.sql | 45 +++ queries/film_filter_language.sql | 15 + queries/language.sql | 14 + sakila-sqlite3 | 1 + sqlc.yaml | 15 + 33 files changed, 2509 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .idea/.gitignore create mode 100644 .idea/dataSources.xml create mode 100644 .idea/form-builder-be-go.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/sqlDataSources.xml create mode 100644 .idea/sqldialects.xml create mode 100644 .idea/vcs.xml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/hal/hal.go create mode 100644 internal/httpapi/actors.go create mode 100644 internal/httpapi/categories.go create mode 100644 internal/httpapi/films.go create mode 100644 internal/httpapi/languages.go create mode 100644 internal/httpapi/root.go create mode 100644 internal/httpapi/util.go create mode 100644 internal/sqlc/actor.sql.go create mode 100644 internal/sqlc/categories.sql.go create mode 100644 internal/sqlc/db.go create mode 100644 internal/sqlc/film.sql.go create mode 100644 internal/sqlc/film_filter_language.sql.go create mode 100644 internal/sqlc/language.sql.go create mode 100644 internal/sqlc/models.go create mode 100644 main.go create mode 100644 queries/actor.sql create mode 100644 queries/categories.sql create mode 100644 queries/film.sql create mode 100644 queries/film_filter_language.sql create mode 100644 queries/language.sql create mode 160000 sakila-sqlite3 create mode 100644 sqlc.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3d83f81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +# .idea/ +# .vscode/ +/form-builder-be-go diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9342f41 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "sakila-sqlite3"] + path = sakila-sqlite3 + url = https://github.com/bradleygrant/sakila-sqlite3.git diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..68577bf --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,19 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/sakila-sqlite3-main/sakila_master.db + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/countries-states-cities-database-master/sqlite/world.sqlite3 + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/form-builder-be-go.iml b/.idea/form-builder-be-go.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/form-builder-be-go.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d20f816 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sqlDataSources.xml b/.idea/sqlDataSources.xml new file mode 100644 index 0000000..0044cd3 --- /dev/null +++ b/.idea/sqlDataSources.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..21a7542 --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4ddeed9 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module form-builder-be-go + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/raff/halgo v0.0.0-20240731162511-e3f9a77e30a8 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/jtacoma/uritemplates v1.0.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..556f742 --- /dev/null +++ b/go.sum @@ -0,0 +1,97 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtacoma/uritemplates v1.0.0 h1:xwx5sBF7pPAb0Uj8lDC1Q/aBPpOFyQza7OC705ZlLCo= +github.com/jtacoma/uritemplates v1.0.0/go.mod h1:IhIICdE9OcvgUnGwTtJxgBQ+VrTrti5PcbLVSJianO8= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/raff/halgo v0.0.0-20240731162511-e3f9a77e30a8 h1:Drb+jhniBV2bcYpBAaMCS/4RZzfPl9GEOGebLOqCIRE= +github.com/raff/halgo v0.0.0-20240731162511-e3f9a77e30a8/go.mod h1:UKFDssjues9OgoSs7BK/CjwLj8oPAR2HvsPuFk7yVNQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/hal/hal.go b/internal/hal/hal.go new file mode 100644 index 0000000..8ec93bb --- /dev/null +++ b/internal/hal/hal.go @@ -0,0 +1,88 @@ +package hal + +import ( + "encoding/json" + + "github.com/raff/halgo" +) + +// Resource is a simple HAL resource envelope that uses halgo.Link for links. +type Resource struct { + Links map[string]halgo.Link `json:"_links,omitempty"` + Embedded map[string]interface{} `json:"_embedded,omitempty"` + // State holds arbitrary resource state merged at top-level when marshaled + State map[string]interface{} `json:"-"` +} + +// New creates a new HAL resource with a self link. +func New(selfHref string) *Resource { + r := &Resource{ + Links: map[string]halgo.Link{}, + Embedded: map[string]interface{}{}, + State: map[string]interface{}{}, + } + if selfHref != "" { + r.AddLink("self", halgo.Link{Href: selfHref}) + } + return r +} + +// AddLink adds a link with the given rel. +func (r *Resource) AddLink(rel string, link halgo.Link) { + if r.Links == nil { + r.Links = map[string]halgo.Link{} + } + r.Links[rel] = link +} + +// AddCurie adds a CURIE link for the sakila namespace. +// Note: HAL recommends an array for the "curies" rel. This implementation uses a single link +// due to the underlying link map being single-valued per rel. Most clients accept this form. +func (r *Resource) AddCurie() { + r.AddLink("curies", halgo.Link{ + Href: "/rels/{rel}", + Templated: true, + Name: "sakila", + }) +} + +// Embed sets an embedded payload under the rel. +func (r *Resource) Embed(rel string, v interface{}) { + if r.Embedded == nil { + r.Embedded = map[string]interface{}{} + } + r.Embedded[rel] = v +} + +// SetState sets or merges state fields. +func (r *Resource) SetState(obj interface{}) { + switch m := obj.(type) { + case map[string]interface{}: + for k, v := range m { + r.State[k] = v + } + default: + // marshal into map and merge + b, _ := json.Marshal(obj) + var mm map[string]interface{} + _ = json.Unmarshal(b, &mm) + for k, v := range mm { + r.State[k] = v + } + } +} + +// MarshalJSON merges State with _links and _embedded following HAL conventions. +func (r *Resource) MarshalJSON() ([]byte, error) { + m := map[string]interface{}{} + for k, v := range r.State { + m[k] = v + } + if len(r.Links) > 0 { + m["_links"] = r.Links + } + if len(r.Embedded) > 0 { + m["_embedded"] = r.Embedded + } + return json.Marshal(m) +} diff --git a/internal/httpapi/actors.go b/internal/httpapi/actors.go new file mode 100644 index 0000000..e80ccbc --- /dev/null +++ b/internal/httpapi/actors.go @@ -0,0 +1,287 @@ +package httpapi + +import ( + "database/sql" + "strconv" + "strings" + + hres "form-builder-be-go/internal/hal" + sq "form-builder-be-go/internal/sqlc" + + "github.com/gin-gonic/gin" + "github.com/raff/halgo" +) + +func RegisterActorRoutes(r *gin.Engine, db *sql.DB) { + // List actors + r.GET("/actors", func(c *gin.Context) { + limit := parseIntDefault(c.Query("limit"), 20) + if limit > 100 { + limit = 100 + } + offset := parseIntDefault(c.Query("offset"), 0) + order := c.Query("order") + expand := parseExpand(c.Query("expand")) + + q := sq.New(db) + actors, err := q.ListActors(c, sq.ListActorsParams{Limit: int64(limit), Offset: int64(offset)}) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + total, err := q.CountActors(c) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + selfURL := buildURL("/actors", limit, offset, order, c.Query("expand")) + res := hres.New(selfURL) + res.AddCurie() + res.AddLink("self", halgo.Link{Href: selfURL}) + if int64(offset+limit) < total { + nextURL := buildURL("/actors", limit, offset+limit, order, c.Query("expand")) + res.AddLink("next", halgo.Link{Href: nextURL}) + } + if offset > 0 { + prev := offset - limit + if prev < 0 { + prev = 0 + } + prevURL := buildURL("/actors", limit, prev, order, c.Query("expand")) + res.AddLink("prev", halgo.Link{Href: prevURL}) + } + + var embedded []interface{} + for _, a := range actors { + itemURL := "/actors/" + strconv.FormatInt(int64(a.ActorID), 10) + relFilmsURL := itemURL + "/films" + item := hres.New(itemURL) + item.SetState(a) + item.AddLink("self", halgo.Link{Href: itemURL}) + item.AddLink("sakila:films", halgo.Link{Href: relFilmsURL}) + if expand["films"] { + films, err := q.ListFilmsByActor(c, int64(a.ActorID)) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + var filmEmbeds []interface{} + for _, f := range films { + filmURL := "/films/" + strconv.FormatInt(f.FilmID, 10) + film := hres.New(filmURL) + film.SetState(f) + film.AddLink("self", halgo.Link{Href: filmURL}) + film.AddLink("sakila:actors", halgo.Link{Href: filmURL + "/actors"}) + filmEmbeds = append(filmEmbeds, film) + } + item.Embed("films", filmEmbeds) + } + embedded = append(embedded, item) + } + res.Embed("actors", embedded) + res.SetState(gin.H{"count": len(actors), "total": total, "limit": limit, "offset": offset}) + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + // Get actor + r.GET("/actors/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + expand := parseExpand(c.Query("expand")) + q := sq.New(db) + aRec, err := q.GetActor(c, id) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + // sqlc returns error for not found + self := "/actors/" + strconv.FormatInt(int64(aRec.ActorID), 10) + relFilms := self + "/films" + res := hres.New(self) + res.AddCurie() + res.SetState(aRec) + res.AddLink("self", halgo.Link{Href: self}) + res.AddLink("sakila:films", halgo.Link{Href: relFilms}) + if expand["films"] { + films, err := q.ListFilmsByActor(c, int64(aRec.ActorID)) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + var filmEmbeds []interface{} + for _, f := range films { + filmURL := "/films/" + strconv.FormatInt(f.FilmID, 10) + film := hres.New(filmURL) + film.SetState(f) + film.AddLink("self", halgo.Link{Href: filmURL}) + film.AddLink("sakila:actors", halgo.Link{Href: filmURL + "/actors"}) + filmEmbeds = append(filmEmbeds, film) + } + res.Embed("films", filmEmbeds) + } + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + // Create actor + r.POST("/actors", func(c *gin.Context) { + var in struct { + ActorID *int64 `json:"actor_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if strings.TrimSpace(in.FirstName) == "" || strings.TrimSpace(in.LastName) == "" { + c.JSON(400, gin.H{"error": "first_name and last_name are required"}) + return + } + q := sq.New(db) + var id int64 + if in.ActorID != nil { + id = *in.ActorID + } else { + var err error + id, err = q.NextActorID(c) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + } + if err := q.InsertActor(c, sq.InsertActorParams{ActorID: id, FirstName: in.FirstName, LastName: in.LastName}); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + created, err := q.GetActor(c, id) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + self := "/actors/" + strconv.FormatInt(int64(created.ActorID), 10) + res := hres.New(self) + res.AddCurie() + res.SetState(created) + res.AddLink("self", halgo.Link{Href: self}) + res.AddLink("sakila:films", halgo.Link{Href: self + "/films"}) + c.Header("Content-Type", "application/hal+json") + c.Header("Location", self) + c.JSON(201, res) + }) + + // Replace actor + r.PUT("/actors/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + var in struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if strings.TrimSpace(in.FirstName) == "" || strings.TrimSpace(in.LastName) == "" { + c.JSON(400, gin.H{"error": "first_name and last_name are required"}) + return + } + q := sq.New(db) + aff, err := q.UpdateActorPut(c, sq.UpdateActorPutParams{FirstName: in.FirstName, LastName: in.LastName, ActorID: id}) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if aff == 0 { + c.JSON(404, gin.H{"error": "not found"}) + return + } + updated, err := q.GetActor(c, id) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + self := "/actors/" + strconv.FormatInt(int64(updated.ActorID), 10) + res := hres.New(self) + res.AddCurie() + res.SetState(updated) + res.AddLink("self", halgo.Link{Href: self}) + res.AddLink("sakila:films", halgo.Link{Href: self + "/films"}) + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + // Patch actor + r.PATCH("/actors/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + var in struct { + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + q := sq.New(db) + toNullString := func(p *string) sql.NullString { + if p == nil { + return sql.NullString{Valid: false} + } + return sql.NullString{String: *p, Valid: true} + } + aff, err := q.PatchActor(c, sq.PatchActorParams{FirstName: toNullString(in.FirstName), LastName: toNullString(in.LastName), ActorID: id}) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if aff == 0 { + c.JSON(404, gin.H{"error": "not found"}) + return + } + updated, err := q.GetActor(c, id) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + self := "/actors/" + strconv.FormatInt(int64(updated.ActorID), 10) + res := hres.New(self) + res.AddCurie() + res.SetState(updated) + res.AddLink("self", halgo.Link{Href: self}) + res.AddLink("sakila:films", halgo.Link{Href: self + "/films"}) + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + // Delete actor + r.DELETE("/actors/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + q := sq.New(db) + aff, err := q.DeleteActor(c, id) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if aff == 0 { + c.JSON(404, gin.H{"error": "not found"}) + return + } + c.Status(204) + }) +} diff --git a/internal/httpapi/categories.go b/internal/httpapi/categories.go new file mode 100644 index 0000000..3cdd041 --- /dev/null +++ b/internal/httpapi/categories.go @@ -0,0 +1,103 @@ +package httpapi + +import ( + "database/sql" + "net/url" + "strconv" + "strings" + + hres "form-builder-be-go/internal/hal" + sq "form-builder-be-go/internal/sqlc" + "github.com/gin-gonic/gin" + "github.com/raff/halgo" +) + +func RegisterCategoryRoutes(r *gin.Engine, db *sql.DB) { + // List categories + r.GET("/categories", func(c *gin.Context) { + limit := parseIntDefault(c.Query("limit"), 20) + if limit > 100 { + limit = 100 + } + offset := parseIntDefault(c.Query("offset"), 0) + order := c.Query("order") // currently static ORDER BY in query; keep for link preservation + + q := sq.New(db) + cats, err := q.ListCategories(c, sq.ListCategoriesParams{Limit: int64(limit), Offset: int64(offset)}) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + total, err := q.CountCategories(c) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + selfURL := buildURL("/categories", limit, offset, order, c.Query("expand")) + res := hres.New(selfURL) + res.AddCurie() + res.AddLink("self", halgo.Link{Href: selfURL}) + if int64(offset+limit) < total { + nextURL := buildURL("/categories", limit, offset+limit, order, c.Query("expand")) + res.AddLink("next", halgo.Link{Href: nextURL}) + } + if offset > 0 { + prev := offset - limit + if prev < 0 { + prev = 0 + } + prevURL := buildURL("/categories", limit, prev, order, c.Query("expand")) + res.AddLink("prev", halgo.Link{Href: prevURL}) + } + + var embedded []interface{} + for _, cat := range cats { + itemURL := "/categories/" + strconv.FormatInt(cat.CategoryID, 10) + item := hres.New(itemURL) + item.SetState(cat) + item.AddLink("self", halgo.Link{Href: itemURL}) + name := strings.TrimSpace(cat.Name) + if name != "" { + qv := url.Values{"category.name": []string{name}} + item.AddLink("sakila:films", halgo.Link{Href: "/films?" + qv.Encode()}) + } + embedded = append(embedded, item) + } + res.Embed("categories", embedded) + res.SetState(gin.H{"count": len(cats), "total": total, "limit": limit, "offset": offset}) + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + // Get category + r.GET("/categories/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + q := sq.New(db) + cat, err := q.GetCategory(c, id) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(404, gin.H{"error": "not found"}) + return + } + c.JSON(500, gin.H{"error": err.Error()}) + return + } + self := "/categories/" + strconv.FormatInt(cat.CategoryID, 10) + res := hres.New(self) + res.AddCurie() + res.SetState(cat) + res.AddLink("self", halgo.Link{Href: self}) + name := strings.TrimSpace(cat.Name) + if name != "" { + qv := url.Values{"category.name": []string{name}} + res.AddLink("sakila:films", halgo.Link{Href: "/films?" + qv.Encode()}) + } + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) +} diff --git a/internal/httpapi/films.go b/internal/httpapi/films.go new file mode 100644 index 0000000..eb757e3 --- /dev/null +++ b/internal/httpapi/films.go @@ -0,0 +1,459 @@ +package httpapi + +import ( + "database/sql" + "net/url" + "strconv" + "strings" + + hres "form-builder-be-go/internal/hal" + sq "form-builder-be-go/internal/sqlc" + + "github.com/gin-gonic/gin" + "github.com/raff/halgo" +) + +func RegisterFilmRoutes(r *gin.Engine, db *sql.DB) { + // List films + r.GET("/films", func(c *gin.Context) { + limit := parseIntDefault(c.Query("limit"), 20) + if limit > 100 { + limit = 100 + } + offset := parseIntDefault(c.Query("offset"), 0) + order := c.Query("order") + expand := parseExpand(c.Query("expand")) + langName := strings.TrimSpace(c.Query("language.name")) + catName := strings.TrimSpace(c.Query("category.name")) + + q := sq.New(db) + var ( + films []sq.Film + total int64 + err error + ) + if langName != "" { + films, err = q.ListFilmsByLanguageName(c, sq.ListFilmsByLanguageNameParams{UPPER: langName, Limit: int64(limit), Offset: int64(offset)}) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + total, err = q.CountFilmsByLanguageName(c, langName) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + } else if catName != "" { + films, err = q.ListFilmsByCategoryName(c, sq.ListFilmsByCategoryNameParams{UPPER: catName, Limit: int64(limit), Offset: int64(offset)}) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + total, err = q.CountFilmsByCategoryName(c, catName) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + } else { + films, err = q.ListFilms(c, sq.ListFilmsParams{Limit: int64(limit), Offset: int64(offset)}) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + total, err = q.CountFilms(c) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + } + + selfURL := buildURL("/films", limit, offset, order, c.Query("expand")) + if langName != "" { + sep := "?" + if strings.Contains(selfURL, "?") { + sep = "&" + } + selfURL = selfURL + sep + "language.name=" + url.QueryEscape(langName) + } + if catName != "" { + sep := "?" + if strings.Contains(selfURL, "?") { + sep = "&" + } + selfURL = selfURL + sep + "category.name=" + url.QueryEscape(catName) + } + res := hres.New(selfURL) + res.AddCurie() + res.AddLink("self", halgo.Link{Href: selfURL}) + if int64(offset+limit) < total { + nextURL := buildURL("/films", limit, offset+limit, order, c.Query("expand")) + if langName != "" { + sep := "?" + if strings.Contains(nextURL, "?") { + sep = "&" + } + nextURL += sep + "language.name=" + url.QueryEscape(langName) + } + if catName != "" { + sep := "?" + if strings.Contains(nextURL, "?") { + sep = "&" + } + nextURL += sep + "category.name=" + url.QueryEscape(catName) + } + res.AddLink("next", halgo.Link{Href: nextURL}) + } + if offset > 0 { + prev := offset - limit + if prev < 0 { + prev = 0 + } + prevURL := buildURL("/films", limit, prev, order, c.Query("expand")) + if langName != "" { + sep := "?" + if strings.Contains(prevURL, "?") { + sep = "&" + } + prevURL += sep + "language.name=" + url.QueryEscape(langName) + } + if catName != "" { + sep := "?" + if strings.Contains(prevURL, "?") { + sep = "&" + } + prevURL += sep + "category.name=" + url.QueryEscape(catName) + } + res.AddLink("prev", halgo.Link{Href: prevURL}) + } + + var embedded []interface{} + for _, f := range films { + itemURL := "/films/" + strconv.FormatInt(f.FilmID, 10) + relActorsURL := itemURL + "/actors" + item := hres.New(itemURL) + item.SetState(f) + item.AddLink("self", halgo.Link{Href: itemURL}) + item.AddLink("sakila:actors", halgo.Link{Href: relActorsURL}) + // language relations + item.AddLink("sakila:language", halgo.Link{Href: "/languages/" + strconv.FormatInt(f.LanguageID, 10)}) + if f.OriginalLanguageID.Valid { + item.AddLink("sakila:original-language", halgo.Link{Href: "/languages/" + strconv.FormatInt(f.OriginalLanguageID.Int64, 10)}) + } + // optional embeds + if expand["language"] { + lang, err := q.GetLanguage(c, f.LanguageID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + lr := hres.New("/languages/" + strconv.FormatInt(lang.LanguageID, 10)) + lr.SetState(lang) + lr.AddLink("self", halgo.Link{Href: "/languages/" + strconv.FormatInt(lang.LanguageID, 10)}) + item.Embed("language", lr) + } + if expand["original_language"] && f.OriginalLanguageID.Valid { + olang, err := q.GetLanguage(c, f.OriginalLanguageID.Int64) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + olr := hres.New("/languages/" + strconv.FormatInt(olang.LanguageID, 10)) + olr.SetState(olang) + olr.AddLink("self", halgo.Link{Href: "/languages/" + strconv.FormatInt(olang.LanguageID, 10)}) + item.Embed("original_language", olr) + } + if expand["actors"] { + actors, err := q.ListActorsByFilm(c, f.FilmID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + var actorEmbeds []interface{} + for _, a := range actors { + actorURL := "/actors/" + strconv.FormatInt(int64(a.ActorID), 10) + ar := hres.New(actorURL) + ar.SetState(a) + ar.AddLink("self", halgo.Link{Href: actorURL}) + ar.AddLink("films", halgo.Link{Href: actorURL + "/films"}) + actorEmbeds = append(actorEmbeds, ar) + } + item.Embed("actors", actorEmbeds) + } + embedded = append(embedded, item) + } + res.Embed("films", embedded) + res.SetState(gin.H{"count": len(films), "total": total, "limit": limit, "offset": offset}) + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + // Get film + r.GET("/films/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + expand := parseExpand(c.Query("expand")) + q := sq.New(db) + f, err := q.GetFilm(c, id) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(404, gin.H{"error": "not found"}) + return + } + c.JSON(500, gin.H{"error": err.Error()}) + return + } + self := "/films/" + strconv.FormatInt(f.FilmID, 10) + relActors := self + "/actors" + res := hres.New(self) + res.AddCurie() + res.SetState(f) + res.AddLink("self", halgo.Link{Href: self}) + res.AddLink("sakila:actors", halgo.Link{Href: relActors}) + // language relations + res.AddLink("sakila:language", halgo.Link{Href: "/languages/" + strconv.FormatInt(f.LanguageID, 10)}) + if f.OriginalLanguageID.Valid { + res.AddLink("sakila:original-language", halgo.Link{Href: "/languages/" + strconv.FormatInt(f.OriginalLanguageID.Int64, 10)}) + } + // optional embeds + if expand["language"] { + lang, err := q.GetLanguage(c, f.LanguageID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + lr := hres.New("/languages/" + strconv.FormatInt(lang.LanguageID, 10)) + lr.SetState(lang) + lr.AddLink("self", halgo.Link{Href: "/languages/" + strconv.FormatInt(lang.LanguageID, 10)}) + res.Embed("language", lr) + } + if expand["original_language"] && f.OriginalLanguageID.Valid { + olang, err := q.GetLanguage(c, f.OriginalLanguageID.Int64) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + olr := hres.New("/languages/" + strconv.FormatInt(olang.LanguageID, 10)) + olr.SetState(olang) + olr.AddLink("self", halgo.Link{Href: "/languages/" + strconv.FormatInt(olang.LanguageID, 10)}) + res.Embed("original_language", olr) + } + if expand["actors"] { + actors, err := q.ListActorsByFilm(c, f.FilmID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + var actorEmbeds []interface{} + for _, a := range actors { + actorURL := "/actors/" + strconv.FormatInt(int64(a.ActorID), 10) + ar := hres.New(actorURL) + ar.SetState(a) + ar.AddLink("self", halgo.Link{Href: actorURL}) + ar.AddLink("films", halgo.Link{Href: actorURL + "/films"}) + actorEmbeds = append(actorEmbeds, ar) + } + res.Embed("actors", actorEmbeds) + } + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + /* + // Create film + r.POST("/films", func(c *gin.Context) { + var in struct { + FilmID *int64 `json:"film_id"` + Title string `json:"title"` + Description *string `json:"description"` + ReleaseYear *string `json:"release_year"` + LanguageID int64 `json:"language_id"` + OriginalLangID *int64 `json:"original_language_id"` + RentalDuration *int64 `json:"rental_duration"` + RentalRate *float64 `json:"rental_rate"` + Length *int64 `json:"length"` + ReplacementCost *float64 `json:"replacement_cost"` + Rating *string `json:"rating"` + SpecialFeatures *string `json:"special_features"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + if strings.TrimSpace(in.Title) == "" || in.LanguageID == 0 { + c.JSON(400, gin.H{"error": "title and language_id are required"}) + return + } + f := sq.Film{Title: in.Title, Description: in.Description, ReleaseYear: in.ReleaseYear, LanguageID: in.LanguageID, OriginalLangID: in.OriginalLangID} + if in.RentalDuration != nil { + f.RentalDuration = *in.RentalDuration + } else { + f.RentalDuration = 3 + } + if in.RentalRate != nil { + f.RentalRate = *in.RentalRate + } else { + f.RentalRate = 4.99 + } + if in.Length != nil { + f.Length = in.Length + } + if in.ReplacementCost != nil { + f.ReplacementCost = *in.ReplacementCost + } else { + f.ReplacementCost = 19.99 + } + if in.Rating != nil { + f.Rating = in.Rating + } + if in.SpecialFeatures != nil { + f.SpecialFeatures = in.SpecialFeatures + } + if in.FilmID != nil { + f.FilmID = *in.FilmID + } + created, err := sq.CreateFilm(c, db, &f) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + self := "/films/" + strconv.FormatInt(created.FilmID, 10) + res := hres.New(self) + res.AddCurie() + res.SetState(created) + res.AddLink("self", halgo.Link{Href: self}) + res.AddLink("sakila:actors", halgo.Link{Href: self + "/actors"}) + c.Header("Content-Type", "application/hal+json") + c.Header("Location", self) + c.JSON(201, res) + }) + + // Replace film + r.PUT("/films/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + var in struct { + Title string `json:"title"` + Description *string `json:"description"` + ReleaseYear *string `json:"release_year"` + LanguageID int64 `json:"language_id"` + OriginalLangID *int64 `json:"original_language_id"` + RentalDuration *int64 `json:"rental_duration"` + RentalRate *float64 `json:"rental_rate"` + Length *int64 `json:"length"` + ReplacementCost *float64 `json:"replacement_cost"` + Rating *string `json:"rating"` + SpecialFeatures *string `json:"special_features"` + } + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + f := sq.Film{Title: in.Title, Description: in.Description, ReleaseYear: in.ReleaseYear, LanguageID: in.LanguageID, OriginalLangID: in.OriginalLangID} + if in.RentalDuration != nil { + f.RentalDuration = *in.RentalDuration + } else { + f.RentalDuration = 3 + } + if in.RentalRate != nil { + f.RentalRate = *in.RentalRate + } else { + f.RentalRate = 4.99 + } + if in.Length != nil { + f.Length = in.Length + } + if in.ReplacementCost != nil { + f.ReplacementCost = *in.ReplacementCost + } else { + f.ReplacementCost = 19.99 + } + if in.Rating != nil { + f.Rating = in.Rating + } + if in.SpecialFeatures != nil { + f.SpecialFeatures = in.SpecialFeatures + } + updated, err := sq.UpdateFilmPut(c, db, id, f) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(404, gin.H{"error": "not found"}) + return + } + c.JSON(400, gin.H{"error": err.Error()}) + return + } + self := "/films/" + strconv.FormatInt(updated.FilmID, 10) + res := hres.New(self) + res.AddCurie() + res.SetState(updated) + res.AddLink("self", halgo.Link{Href: self}) + res.AddLink("sakila:actors", halgo.Link{Href: self + "/actors"}) + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + // Patch film + r.PATCH("/films/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + var in struct { + Title *string `json:"title"` + Description *string `json:"description"` + ReleaseYear *string `json:"release_year"` + LanguageID *int64 `json:"language_id"` + OriginalLangID *int64 `json:"original_language_id"` + RentalDuration *int64 `json:"rental_duration"` + RentalRate *float64 `json:"rental_rate"` + Length *int64 `json:"length"` + ReplacementCost *float64 `json:"replacement_cost"` + Rating *string `json:"rating"` + SpecialFeatures *string `json:"special_features"` + } + patch := sq.FilmPatch{Title: in.Title, Description: in.Description, ReleaseYear: in.ReleaseYear, LanguageID: in.LanguageID, OriginalLangID: in.OriginalLangID, RentalDuration: in.RentalDuration, RentalRate: in.RentalRate, Length: in.Length, ReplacementCost: in.ReplacementCost, Rating: in.Rating, SpecialFeatures: in.SpecialFeatures} + updated, err := sq.PatchFilm(c, db, id, patch) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(404, gin.H{"error": "not found"}) + return + } + c.JSON(400, gin.H{"error": err.Error()}) + return + } + self := "/films/" + strconv.FormatInt(updated.FilmID, 10) + res := hres.New(self) + res.AddCurie() + res.SetState(updated) + res.AddLink("self", halgo.Link{Href: self}) + res.AddLink("sakila:actors", halgo.Link{Href: self + "/actors"}) + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + // Delete film + r.DELETE("/films/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + if err := sq.DeleteFilm(c, db, id); err != nil { + if err == sql.ErrNoRows { + c.JSON(404, gin.H{"error": "not found"}) + return + } + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.Status(204) + }) + */ +} diff --git a/internal/httpapi/languages.go b/internal/httpapi/languages.go new file mode 100644 index 0000000..ec0412f --- /dev/null +++ b/internal/httpapi/languages.go @@ -0,0 +1,106 @@ +package httpapi + +import ( + "database/sql" + "fmt" + "net/url" + "strconv" + "strings" + + hres "form-builder-be-go/internal/hal" + sq "form-builder-be-go/internal/sqlc" + + "github.com/gin-gonic/gin" + "github.com/raff/halgo" +) + +func RegisterLanguageRoutes(r *gin.Engine, db *sql.DB) { + // List languages + r.GET("/languages", func(c *gin.Context) { + limit := parseIntDefault(c.Query("limit"), 20) + if limit > 100 { + limit = 100 + } + offset := parseIntDefault(c.Query("offset"), 0) + order := c.Query("order") + + q := sq.New(db) + langs, err := q.ListLanguages(c, sq.ListLanguagesParams{Limit: int64(limit), Offset: int64(offset)}) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + total, err := q.CountLanguages(c) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + selfURL := buildURL("/languages", limit, offset, order, c.Query("expand")) + res := hres.New(selfURL) + res.AddCurie() + res.AddLink("self", halgo.Link{Href: selfURL}) + if int64(offset+limit) < total { + nextURL := buildURL("/languages", limit, offset+limit, order, c.Query("expand")) + res.AddLink("next", halgo.Link{Href: nextURL}) + } + if offset > 0 { + prev := offset - limit + if prev < 0 { + prev = 0 + } + prevURL := buildURL("/languages", limit, prev, order, c.Query("expand")) + res.AddLink("prev", halgo.Link{Href: prevURL}) + } + + var embedded []interface{} + for _, l := range langs { + itemURL := "/languages/" + strconv.FormatInt(l.LanguageID, 10) + item := hres.New(itemURL) + item.SetState(l) + item.AddLink("self", halgo.Link{Href: itemURL}) + // Convenience link to films filtered by this language name + name := strings.TrimSpace(fmt.Sprint(l.Name)) + if name != "" { + qv := url.Values{"language.name": []string{name}} + item.AddLink("sakila:films", halgo.Link{Href: "/films?" + qv.Encode()}) + } + embedded = append(embedded, item) + } + res.Embed("languages", embedded) + res.SetState(gin.H{"count": len(langs), "total": total, "limit": limit, "offset": offset}) + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) + + // Get language + r.GET("/languages/:id", func(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(400, gin.H{"error": "invalid id"}) + return + } + q := sq.New(db) + l, err := q.GetLanguage(c, id) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(404, gin.H{"error": "not found"}) + return + } + c.JSON(500, gin.H{"error": err.Error()}) + return + } + self := "/languages/" + strconv.FormatInt(l.LanguageID, 10) + res := hres.New(self) + res.AddCurie() + res.SetState(l) + res.AddLink("self", halgo.Link{Href: self}) + name := strings.TrimSpace(fmt.Sprint(l.Name)) + if name != "" { + qv := url.Values{"language.name": []string{name}} + res.AddLink("sakila:films", halgo.Link{Href: "/films?" + qv.Encode()}) + } + c.Header("Content-Type", "application/hal+json") + c.JSON(200, res) + }) +} diff --git a/internal/httpapi/root.go b/internal/httpapi/root.go new file mode 100644 index 0000000..2e86ff9 --- /dev/null +++ b/internal/httpapi/root.go @@ -0,0 +1,22 @@ +package httpapi + +import ( + "database/sql" + hres "form-builder-be-go/internal/hal" + + "github.com/gin-gonic/gin" + "github.com/raff/halgo" +) + +func RegisterRoot(r *gin.Engine, db *sql.DB) { + r.GET("/", func(c *gin.Context) { + root := hres.New("/") + root.AddCurie() + root.AddLink("sakila:actors", halgo.Link{Href: "/actors"}) + root.AddLink("sakila:films", halgo.Link{Href: "/films"}) + root.AddLink("sakila:languages", halgo.Link{Href: "/languages"}) + root.AddLink("sakila:categories", halgo.Link{Href: "/categories"}) + c.Header("Content-Type", "application/hal+json") + c.JSON(200, root) + }) +} diff --git a/internal/httpapi/util.go b/internal/httpapi/util.go new file mode 100644 index 0000000..f9bfaa1 --- /dev/null +++ b/internal/httpapi/util.go @@ -0,0 +1,53 @@ +package httpapi + +import ( + "net/url" + "strconv" + "strings" +) + +func parseIntDefault(s string, def int) int { + if s == "" { + return def + } + v, err := strconv.Atoi(s) + if err != nil { + return def + } + return v +} + +func parseExpand(s string) map[string]bool { + m := map[string]bool{} + if strings.TrimSpace(s) == "" { + return m + } + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + m[p] = true + } + } + return m +} + +func buildURL(base string, limit, offset int, order, expand string) string { + v := url.Values{} + if limit > 0 { + v.Set("limit", strconv.Itoa(limit)) + } + if offset > 0 { + v.Set("offset", strconv.Itoa(offset)) + } + if strings.TrimSpace(order) != "" { + v.Set("order", order) + } + if strings.TrimSpace(expand) != "" { + v.Set("expand", expand) + } + qs := v.Encode() + if qs == "" { + return base + } + return base + "?" + qs +} diff --git a/internal/sqlc/actor.sql.go b/internal/sqlc/actor.sql.go new file mode 100644 index 0000000..0e03a4b --- /dev/null +++ b/internal/sqlc/actor.sql.go @@ -0,0 +1,219 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: actor.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const countActors = `-- name: CountActors :one +SELECT COUNT(*) +FROM actor +` + +func (q *Queries) CountActors(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countActors) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteActor = `-- name: DeleteActor :execrows +DELETE FROM actor WHERE actor_id = ? +` + +func (q *Queries) DeleteActor(ctx context.Context, actorID int64) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteActor, actorID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getActor = `-- name: GetActor :one +SELECT actor_id, first_name, last_name, last_update +FROM actor +WHERE actor_id = ? +` + +func (q *Queries) GetActor(ctx context.Context, actorID int64) (Actor, error) { + row := q.db.QueryRowContext(ctx, getActor, actorID) + var i Actor + err := row.Scan( + &i.ActorID, + &i.FirstName, + &i.LastName, + &i.LastUpdate, + ) + return i, err +} + +const insertActor = `-- name: InsertActor :exec +INSERT INTO actor (actor_id, first_name, last_name, last_update) +VALUES (?, ?, ?, CURRENT_TIMESTAMP) +` + +type InsertActorParams struct { + ActorID int64 `json:"actor_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +func (q *Queries) InsertActor(ctx context.Context, arg InsertActorParams) error { + _, err := q.db.ExecContext(ctx, insertActor, arg.ActorID, arg.FirstName, arg.LastName) + return err +} + +const listActors = `-- name: ListActors :many +SELECT actor_id, first_name, last_name, last_update +FROM actor +ORDER BY actor_id ASC +LIMIT ? OFFSET ? +` + +type ListActorsParams struct { + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` +} + +func (q *Queries) ListActors(ctx context.Context, arg ListActorsParams) ([]Actor, error) { + rows, err := q.db.QueryContext(ctx, listActors, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Actor + for rows.Next() { + var i Actor + if err := rows.Scan( + &i.ActorID, + &i.FirstName, + &i.LastName, + &i.LastUpdate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFilmsByActor = `-- name: ListFilmsByActor :many +SELECT f.film_id, + f.title, + f.description, + f.release_year, + f.language_id, + f.original_language_id, + f.rental_duration, + f.rental_rate, + f.length, + f.replacement_cost, + f.rating, + f.special_features, + f.last_update +FROM film f + JOIN film_actor fa ON fa.film_id = f.film_id +WHERE fa.actor_id = ? +ORDER BY f.title ASC +` + +func (q *Queries) ListFilmsByActor(ctx context.Context, actorID int64) ([]Film, error) { + rows, err := q.db.QueryContext(ctx, listFilmsByActor, actorID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Film + for rows.Next() { + var i Film + if err := rows.Scan( + &i.FilmID, + &i.Title, + &i.Description, + &i.ReleaseYear, + &i.LanguageID, + &i.OriginalLanguageID, + &i.RentalDuration, + &i.RentalRate, + &i.Length, + &i.ReplacementCost, + &i.Rating, + &i.SpecialFeatures, + &i.LastUpdate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const nextActorID = `-- name: NextActorID :one +SELECT COALESCE(MAX(actor_id), 0) + 1 FROM actor +` + +func (q *Queries) NextActorID(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, nextActorID) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} + +const patchActor = `-- name: PatchActor :execrows +UPDATE actor +SET first_name = COALESCE(?1, first_name), + last_name = COALESCE(?2, last_name) +WHERE actor_id = ?3 +` + +type PatchActorParams struct { + FirstName sql.NullString `json:"first_name"` + LastName sql.NullString `json:"last_name"` + ActorID int64 `json:"actor_id"` +} + +func (q *Queries) PatchActor(ctx context.Context, arg PatchActorParams) (int64, error) { + result, err := q.db.ExecContext(ctx, patchActor, arg.FirstName, arg.LastName, arg.ActorID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateActorPut = `-- name: UpdateActorPut :execrows +UPDATE actor +SET first_name = ?, + last_name = ? +WHERE actor_id = ? +` + +type UpdateActorPutParams struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + ActorID int64 `json:"actor_id"` +} + +func (q *Queries) UpdateActorPut(ctx context.Context, arg UpdateActorPutParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateActorPut, arg.FirstName, arg.LastName, arg.ActorID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/sqlc/categories.sql.go b/internal/sqlc/categories.sql.go new file mode 100644 index 0000000..e183904 --- /dev/null +++ b/internal/sqlc/categories.sql.go @@ -0,0 +1,139 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: categories.sql + +package sqlc + +import ( + "context" +) + +const countCategories = `-- name: CountCategories :one +SELECT COUNT(*) FROM category +` + +func (q *Queries) CountCategories(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countCategories) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countFilmsByCategoryName = `-- name: CountFilmsByCategoryName :one +SELECT COUNT(*) +FROM film f +JOIN film_category fc ON fc.film_id = f.film_id +JOIN category c ON c.category_id = fc.category_id +WHERE UPPER(c.name) = UPPER(?) +` + +func (q *Queries) CountFilmsByCategoryName(ctx context.Context, upper string) (int64, error) { + row := q.db.QueryRowContext(ctx, countFilmsByCategoryName, upper) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getCategory = `-- name: GetCategory :one +SELECT category_id, name, last_update +FROM category +WHERE category_id = ? +` + +func (q *Queries) GetCategory(ctx context.Context, categoryID int64) (Category, error) { + row := q.db.QueryRowContext(ctx, getCategory, categoryID) + var i Category + err := row.Scan(&i.CategoryID, &i.Name, &i.LastUpdate) + return i, err +} + +const listCategories = `-- name: ListCategories :many +SELECT category_id, name, last_update +FROM category +ORDER BY category_id ASC +LIMIT ? OFFSET ? +` + +type ListCategoriesParams struct { + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` +} + +func (q *Queries) ListCategories(ctx context.Context, arg ListCategoriesParams) ([]Category, error) { + rows, err := q.db.QueryContext(ctx, listCategories, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Category + for rows.Next() { + var i Category + if err := rows.Scan(&i.CategoryID, &i.Name, &i.LastUpdate); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFilmsByCategoryName = `-- name: ListFilmsByCategoryName :many +SELECT f.film_id, f.title, f.description, f.release_year, f.language_id, + f.original_language_id, f.rental_duration, f.rental_rate, f.length, + f.replacement_cost, f.rating, f.special_features, f.last_update +FROM film f +JOIN film_category fc ON fc.film_id = f.film_id +JOIN category c ON c.category_id = fc.category_id +WHERE UPPER(c.name) = UPPER(?) +ORDER BY f.film_id ASC +LIMIT ? OFFSET ? +` + +type ListFilmsByCategoryNameParams struct { + UPPER string `json:"UPPER"` + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` +} + +func (q *Queries) ListFilmsByCategoryName(ctx context.Context, arg ListFilmsByCategoryNameParams) ([]Film, error) { + rows, err := q.db.QueryContext(ctx, listFilmsByCategoryName, arg.UPPER, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Film + for rows.Next() { + var i Film + if err := rows.Scan( + &i.FilmID, + &i.Title, + &i.Description, + &i.ReleaseYear, + &i.LanguageID, + &i.OriginalLanguageID, + &i.RentalDuration, + &i.RentalRate, + &i.Length, + &i.ReplacementCost, + &i.Rating, + &i.SpecialFeatures, + &i.LastUpdate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/sqlc/db.go b/internal/sqlc/db.go new file mode 100644 index 0000000..e4d7828 --- /dev/null +++ b/internal/sqlc/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package sqlc + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/sqlc/film.sql.go b/internal/sqlc/film.sql.go new file mode 100644 index 0000000..35be453 --- /dev/null +++ b/internal/sqlc/film.sql.go @@ -0,0 +1,158 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: film.sql + +package sqlc + +import ( + "context" +) + +const countFilms = `-- name: CountFilms :one +SELECT COUNT(*) +FROM film +` + +func (q *Queries) CountFilms(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countFilms) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getFilm = `-- name: GetFilm :one +SELECT film_id, + title, + description, + release_year, + language_id, + original_language_id, + rental_duration, + rental_rate, + length, + replacement_cost, + rating, + special_features, + last_update +FROM film +WHERE film_id = ? +` + +func (q *Queries) GetFilm(ctx context.Context, filmID int64) (Film, error) { + row := q.db.QueryRowContext(ctx, getFilm, filmID) + var i Film + err := row.Scan( + &i.FilmID, + &i.Title, + &i.Description, + &i.ReleaseYear, + &i.LanguageID, + &i.OriginalLanguageID, + &i.RentalDuration, + &i.RentalRate, + &i.Length, + &i.ReplacementCost, + &i.Rating, + &i.SpecialFeatures, + &i.LastUpdate, + ) + return i, err +} + +const listActorsByFilm = `-- name: ListActorsByFilm :many +SELECT a.actor_id, a.first_name, a.last_name, a.last_update +FROM actor a + JOIN film_actor fa ON fa.actor_id = a.actor_id +WHERE fa.film_id = ? +ORDER BY a.last_name ASC, a.first_name ASC +` + +func (q *Queries) ListActorsByFilm(ctx context.Context, filmID int64) ([]Actor, error) { + rows, err := q.db.QueryContext(ctx, listActorsByFilm, filmID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Actor + for rows.Next() { + var i Actor + if err := rows.Scan( + &i.ActorID, + &i.FirstName, + &i.LastName, + &i.LastUpdate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFilms = `-- name: ListFilms :many +SELECT film_id, + title, + description, + release_year, + language_id, + original_language_id, + rental_duration, + rental_rate, + length, + replacement_cost, + rating, + special_features, + last_update +FROM film +ORDER BY film_id ASC +LIMIT ? OFFSET ? +` + +type ListFilmsParams struct { + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` +} + +func (q *Queries) ListFilms(ctx context.Context, arg ListFilmsParams) ([]Film, error) { + rows, err := q.db.QueryContext(ctx, listFilms, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Film + for rows.Next() { + var i Film + if err := rows.Scan( + &i.FilmID, + &i.Title, + &i.Description, + &i.ReleaseYear, + &i.LanguageID, + &i.OriginalLanguageID, + &i.RentalDuration, + &i.RentalRate, + &i.Length, + &i.ReplacementCost, + &i.Rating, + &i.SpecialFeatures, + &i.LastUpdate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/sqlc/film_filter_language.sql.go b/internal/sqlc/film_filter_language.sql.go new file mode 100644 index 0000000..854f3c1 --- /dev/null +++ b/internal/sqlc/film_filter_language.sql.go @@ -0,0 +1,78 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: film_filter_language.sql + +package sqlc + +import ( + "context" +) + +const countFilmsByLanguageName = `-- name: CountFilmsByLanguageName :one +SELECT COUNT(*) +FROM film f +JOIN language l ON l.language_id = f.language_id +WHERE UPPER(l.name) = UPPER(?) +` + +func (q *Queries) CountFilmsByLanguageName(ctx context.Context, upper string) (int64, error) { + row := q.db.QueryRowContext(ctx, countFilmsByLanguageName, upper) + var count int64 + err := row.Scan(&count) + return count, err +} + +const listFilmsByLanguageName = `-- name: ListFilmsByLanguageName :many +SELECT f.film_id, f.title, f.description, f.release_year, f.language_id, + f.original_language_id, f.rental_duration, f.rental_rate, f.length, + f.replacement_cost, f.rating, f.special_features, f.last_update +FROM film f +JOIN language l ON l.language_id = f.language_id +WHERE UPPER(l.name) = UPPER(?) +ORDER BY f.film_id ASC +LIMIT ? OFFSET ? +` + +type ListFilmsByLanguageNameParams struct { + UPPER string `json:"UPPER"` + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` +} + +func (q *Queries) ListFilmsByLanguageName(ctx context.Context, arg ListFilmsByLanguageNameParams) ([]Film, error) { + rows, err := q.db.QueryContext(ctx, listFilmsByLanguageName, arg.UPPER, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Film + for rows.Next() { + var i Film + if err := rows.Scan( + &i.FilmID, + &i.Title, + &i.Description, + &i.ReleaseYear, + &i.LanguageID, + &i.OriginalLanguageID, + &i.RentalDuration, + &i.RentalRate, + &i.Length, + &i.ReplacementCost, + &i.Rating, + &i.SpecialFeatures, + &i.LastUpdate, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/sqlc/language.sql.go b/internal/sqlc/language.sql.go new file mode 100644 index 0000000..2ae4ab7 --- /dev/null +++ b/internal/sqlc/language.sql.go @@ -0,0 +1,70 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: language.sql + +package sqlc + +import ( + "context" +) + +const countLanguages = `-- name: CountLanguages :one +SELECT COUNT(*) +FROM language +` + +func (q *Queries) CountLanguages(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countLanguages) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getLanguage = `-- name: GetLanguage :one +SELECT language_id, name, last_update +FROM language +WHERE language_id = ? +` + +func (q *Queries) GetLanguage(ctx context.Context, languageID int64) (Language, error) { + row := q.db.QueryRowContext(ctx, getLanguage, languageID) + var i Language + err := row.Scan(&i.LanguageID, &i.Name, &i.LastUpdate) + return i, err +} + +const listLanguages = `-- name: ListLanguages :many +SELECT language_id, name, last_update +FROM language +ORDER BY language_id ASC +LIMIT ? OFFSET ? +` + +type ListLanguagesParams struct { + Limit int64 `json:"limit"` + Offset int64 `json:"offset"` +} + +func (q *Queries) ListLanguages(ctx context.Context, arg ListLanguagesParams) ([]Language, error) { + rows, err := q.db.QueryContext(ctx, listLanguages, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Language + for rows.Next() { + var i Language + if err := rows.Scan(&i.LanguageID, &i.Name, &i.LastUpdate); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/sqlc/models.go b/internal/sqlc/models.go new file mode 100644 index 0000000..3acedc5 --- /dev/null +++ b/internal/sqlc/models.go @@ -0,0 +1,193 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 + +package sqlc + +import ( + "database/sql" + "time" +) + +type Actor struct { + ActorID int64 `json:"actor_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + LastUpdate time.Time `json:"last_update"` +} + +type Address struct { + AddressID int64 `json:"address_id"` + Address string `json:"address"` + Address2 sql.NullString `json:"address2"` + District string `json:"district"` + CityID int64 `json:"city_id"` + PostalCode sql.NullString `json:"postal_code"` + Phone string `json:"phone"` + LastUpdate time.Time `json:"last_update"` +} + +type Category struct { + CategoryID int64 `json:"category_id"` + Name string `json:"name"` + LastUpdate time.Time `json:"last_update"` +} + +type City struct { + CityID int64 `json:"city_id"` + City string `json:"city"` + CountryID int64 `json:"country_id"` + LastUpdate time.Time `json:"last_update"` +} + +type Country struct { + CountryID int64 `json:"country_id"` + Country string `json:"country"` + LastUpdate sql.NullTime `json:"last_update"` +} + +type Customer struct { + CustomerID int64 `json:"customer_id"` + StoreID int64 `json:"store_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email sql.NullString `json:"email"` + AddressID int64 `json:"address_id"` + Active interface{} `json:"active"` + CreateDate time.Time `json:"create_date"` + LastUpdate time.Time `json:"last_update"` +} + +type CustomerList struct { + ID int64 `json:"id"` + Name interface{} `json:"name"` + Address string `json:"address"` + ZipCode sql.NullString `json:"zip_code"` + Phone string `json:"phone"` + City string `json:"city"` + Country string `json:"country"` + Notes string `json:"notes"` + Sid int64 `json:"sid"` +} + +type Film struct { + FilmID int64 `json:"film_id"` + Title string `json:"title"` + Description interface{} `json:"description"` + ReleaseYear sql.NullString `json:"release_year"` + LanguageID int64 `json:"language_id"` + OriginalLanguageID sql.NullInt64 `json:"original_language_id"` + RentalDuration int64 `json:"rental_duration"` + RentalRate float64 `json:"rental_rate"` + Length sql.NullInt64 `json:"length"` + ReplacementCost float64 `json:"replacement_cost"` + Rating sql.NullString `json:"rating"` + SpecialFeatures sql.NullString `json:"special_features"` + LastUpdate time.Time `json:"last_update"` +} + +type FilmActor struct { + ActorID int64 `json:"actor_id"` + FilmID int64 `json:"film_id"` + LastUpdate time.Time `json:"last_update"` +} + +type FilmCategory struct { + FilmID int64 `json:"film_id"` + CategoryID int64 `json:"category_id"` + LastUpdate time.Time `json:"last_update"` +} + +type FilmList struct { + Fid int64 `json:"fid"` + Title string `json:"title"` + Description interface{} `json:"description"` + Category string `json:"category"` + Price float64 `json:"price"` + Length sql.NullInt64 `json:"length"` + Rating sql.NullString `json:"rating"` + Actors interface{} `json:"actors"` +} + +type FilmText struct { + FilmID int64 `json:"film_id"` + Title string `json:"title"` + Description interface{} `json:"description"` +} + +type Inventory struct { + InventoryID int64 `json:"inventory_id"` + FilmID int64 `json:"film_id"` + StoreID int64 `json:"store_id"` + LastUpdate time.Time `json:"last_update"` +} + +type Language struct { + LanguageID int64 `json:"language_id"` + Name interface{} `json:"name"` + LastUpdate time.Time `json:"last_update"` +} + +type Payment struct { + PaymentID int64 `json:"payment_id"` + CustomerID int64 `json:"customer_id"` + StaffID int64 `json:"staff_id"` + RentalID sql.NullInt64 `json:"rental_id"` + Amount float64 `json:"amount"` + PaymentDate time.Time `json:"payment_date"` + LastUpdate time.Time `json:"last_update"` +} + +type Rental struct { + RentalID int64 `json:"rental_id"` + RentalDate time.Time `json:"rental_date"` + InventoryID int64 `json:"inventory_id"` + CustomerID int64 `json:"customer_id"` + ReturnDate sql.NullTime `json:"return_date"` + StaffID int64 `json:"staff_id"` + LastUpdate time.Time `json:"last_update"` +} + +type SalesByFilmCategory struct { + Category string `json:"category"` + TotalSales sql.NullFloat64 `json:"total_sales"` +} + +type SalesByStore struct { + StoreID int64 `json:"store_id"` + Store interface{} `json:"store"` + Manager interface{} `json:"manager"` + TotalSales sql.NullFloat64 `json:"total_sales"` +} + +type Staff struct { + StaffID int64 `json:"staff_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + AddressID int64 `json:"address_id"` + Picture []byte `json:"picture"` + Email sql.NullString `json:"email"` + StoreID int64 `json:"store_id"` + Active int64 `json:"active"` + Username string `json:"username"` + Password sql.NullString `json:"password"` + LastUpdate time.Time `json:"last_update"` +} + +type StaffList struct { + ID int64 `json:"id"` + Name interface{} `json:"name"` + Address string `json:"address"` + ZipCode sql.NullString `json:"zip_code"` + Phone string `json:"phone"` + City string `json:"city"` + Country string `json:"country"` + Sid int64 `json:"sid"` +} + +type Store struct { + StoreID int64 `json:"store_id"` + ManagerStaffID int64 `json:"manager_staff_id"` + AddressID int64 `json:"address_id"` + LastUpdate time.Time `json:"last_update"` +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..eb522bf --- /dev/null +++ b/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "database/sql" + "log" + "net/http" + "os" + + "github.com/gin-gonic/gin" + _ "github.com/mattn/go-sqlite3" + + "form-builder-be-go/internal/httpapi" +) + +func getEnv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func CorsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") // Or specify specific origins + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + + // Handle OPTIONS preflight request + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} + +func main() { + // DB path can be overridden via env + dbPath := getEnv("SAKILA_DB", "sakila-sqlite3/sakila_master.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + log.Fatalf("open db: %v", err) + } + defer db.Close() + + r := gin.Default() + + // Add the CORS middleware + r.Use(CorsMiddleware()) + + httpapi.RegisterRoot(r, db) + httpapi.RegisterActorRoutes(r, db) + httpapi.RegisterFilmRoutes(r, db) + httpapi.RegisterLanguageRoutes(r, db) + httpapi.RegisterCategoryRoutes(r, db) + + // Start server + addr := getEnv("ADDR", ":8080") + log.Printf("Listening on %s (DB=%s)", addr, dbPath) + if err := r.Run(addr); err != nil { + log.Fatalf("server: %v", err) + } +} diff --git a/queries/actor.sql b/queries/actor.sql new file mode 100644 index 0000000..49e36d9 --- /dev/null +++ b/queries/actor.sql @@ -0,0 +1,55 @@ +-- name: ListActors :many +SELECT actor_id, first_name, last_name, last_update +FROM actor +ORDER BY actor_id ASC +LIMIT ? OFFSET ?; + +-- name: CountActors :one +SELECT COUNT(*) +FROM actor; + +-- name: GetActor :one +SELECT actor_id, first_name, last_name, last_update +FROM actor +WHERE actor_id = ?; + +-- name: ListFilmsByActor :many +SELECT f.film_id, + f.title, + f.description, + f.release_year, + f.language_id, + f.original_language_id, + f.rental_duration, + f.rental_rate, + f.length, + f.replacement_cost, + f.rating, + f.special_features, + f.last_update +FROM film f + JOIN film_actor fa ON fa.film_id = f.film_id +WHERE fa.actor_id = ? +ORDER BY f.title ASC; + +-- name: NextActorID :one +SELECT COALESCE(MAX(actor_id), 0) + 1 FROM actor; + +-- name: InsertActor :exec +INSERT INTO actor (actor_id, first_name, last_name, last_update) +VALUES (?, ?, ?, CURRENT_TIMESTAMP); + +-- name: UpdateActorPut :execrows +UPDATE actor +SET first_name = ?, + last_name = ? +WHERE actor_id = ?; + +-- name: PatchActor :execrows +UPDATE actor +SET first_name = COALESCE(sqlc.narg(first_name), first_name), + last_name = COALESCE(sqlc.narg(last_name), last_name) +WHERE actor_id = sqlc.arg(actor_id); + +-- name: DeleteActor :execrows +DELETE FROM actor WHERE actor_id = ?; diff --git a/queries/categories.sql b/queries/categories.sql new file mode 100644 index 0000000..cd340d2 --- /dev/null +++ b/queries/categories.sql @@ -0,0 +1,31 @@ +-- name: ListCategories :many +SELECT category_id, name, last_update +FROM category +ORDER BY category_id ASC +LIMIT ? OFFSET ?; + +-- name: CountCategories :one +SELECT COUNT(*) FROM category; + +-- name: GetCategory :one +SELECT category_id, name, last_update +FROM category +WHERE category_id = ?; + +-- name: ListFilmsByCategoryName :many +SELECT f.film_id, f.title, f.description, f.release_year, f.language_id, + f.original_language_id, f.rental_duration, f.rental_rate, f.length, + f.replacement_cost, f.rating, f.special_features, f.last_update +FROM film f +JOIN film_category fc ON fc.film_id = f.film_id +JOIN category c ON c.category_id = fc.category_id +WHERE UPPER(c.name) = UPPER(?) +ORDER BY f.film_id ASC +LIMIT ? OFFSET ?; + +-- name: CountFilmsByCategoryName :one +SELECT COUNT(*) +FROM film f +JOIN film_category fc ON fc.film_id = f.film_id +JOIN category c ON c.category_id = fc.category_id +WHERE UPPER(c.name) = UPPER(?); diff --git a/queries/film.sql b/queries/film.sql new file mode 100644 index 0000000..9df6279 --- /dev/null +++ b/queries/film.sql @@ -0,0 +1,45 @@ +-- name: ListFilms :many +SELECT film_id, + title, + description, + release_year, + language_id, + original_language_id, + rental_duration, + rental_rate, + length, + replacement_cost, + rating, + special_features, + last_update +FROM film +ORDER BY film_id ASC +LIMIT ? OFFSET ?; + +-- name: CountFilms :one +SELECT COUNT(*) +FROM film; + +-- name: GetFilm :one +SELECT film_id, + title, + description, + release_year, + language_id, + original_language_id, + rental_duration, + rental_rate, + length, + replacement_cost, + rating, + special_features, + last_update +FROM film +WHERE film_id = ?; + +-- name: ListActorsByFilm :many +SELECT a.actor_id, a.first_name, a.last_name, a.last_update +FROM actor a + JOIN film_actor fa ON fa.actor_id = a.actor_id +WHERE fa.film_id = ? +ORDER BY a.last_name ASC, a.first_name ASC; \ No newline at end of file diff --git a/queries/film_filter_language.sql b/queries/film_filter_language.sql new file mode 100644 index 0000000..7b9b3c4 --- /dev/null +++ b/queries/film_filter_language.sql @@ -0,0 +1,15 @@ +-- name: ListFilmsByLanguageName :many +SELECT f.film_id, f.title, f.description, f.release_year, f.language_id, + f.original_language_id, f.rental_duration, f.rental_rate, f.length, + f.replacement_cost, f.rating, f.special_features, f.last_update +FROM film f +JOIN language l ON l.language_id = f.language_id +WHERE UPPER(l.name) = UPPER(?) +ORDER BY f.film_id ASC +LIMIT ? OFFSET ?; + +-- name: CountFilmsByLanguageName :one +SELECT COUNT(*) +FROM film f +JOIN language l ON l.language_id = f.language_id +WHERE UPPER(l.name) = UPPER(?); diff --git a/queries/language.sql b/queries/language.sql new file mode 100644 index 0000000..e099f9c --- /dev/null +++ b/queries/language.sql @@ -0,0 +1,14 @@ +-- name: ListLanguages :many +SELECT language_id, name, last_update +FROM language +ORDER BY language_id ASC +LIMIT ? OFFSET ?; + +-- name: CountLanguages :one +SELECT COUNT(*) +FROM language; + +-- name: GetLanguage :one +SELECT language_id, name, last_update +FROM language +WHERE language_id = ?; diff --git a/sakila-sqlite3 b/sakila-sqlite3 new file mode 160000 index 0000000..9394b42 --- /dev/null +++ b/sakila-sqlite3 @@ -0,0 +1 @@ +Subproject commit 9394b42d13888c3d3d3d56cd7e9c84fadafb71c7 diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..ffc299d --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,15 @@ +version: "2" +sql: + - engine: "sqlite" + schema: + - "sakila-sqlite3-main/source/sqlite-sakila-schema.sql" + queries: + - "queries/*.sql" + gen: + go: + package: "sqlc" + out: "internal/sqlc" + emit_json_tags: true + emit_prepared_queries: false + emit_interface: false + emit_exact_table_names: true