├── staticcheck.conf ├── update.sh ├── drivers.go ├── scripts ├── token_query.sql ├── metadata.sql ├── list_columns_postgres_sqlserver.sql ├── list_columns_oracle.sql ├── init_oracle.sql └── init.sql ├── .gitignore ├── test-dockers ├── Dockerfile.mysql ├── Dockerfile.postgresql ├── Dockerfile.mariadb └── Dockerfile.mssql ├── release.sh ├── main.go ├── utils_test.go ├── test.sh ├── LICENSE ├── tests ├── sqlite.json ├── sqlite3.json ├── sqlserver.json ├── oracle.json ├── pgx.json ├── postgres.json ├── mysql.json └── mariadb.json ├── gosqlapi.json ├── go.mod ├── types.go ├── utils.go ├── api_test.go ├── go.sum ├── README.md └── gosqlapi.go /staticcheck.conf: -------------------------------------------------------------------------------- 1 | checks = ["all", "-ST1003", "-ST1005", "-ST1006", "-QF1003"] -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | egrep -v "^go \d+\.\d+\.\d+$" go.mod > go.mod.tmp 4 | mv go.mod.tmp go.mod 5 | go mod tidy 6 | 7 | GOPROXY=direct go get -u -t 8 | go mod tidy 9 | -------------------------------------------------------------------------------- /drivers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "github.com/go-sql-driver/mysql" 5 | _ "github.com/jackc/pgx/v5/stdlib" 6 | _ "github.com/lib/pq" 7 | 8 | // _ "github.com/mattn/go-sqlite3" 9 | _ "github.com/microsoft/go-mssqldb" 10 | _ "github.com/sijms/go-ora/v2" 11 | _ "modernc.org/sqlite" 12 | ) 13 | -------------------------------------------------------------------------------- /scripts/token_query.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | TARGET_DATABASE AS target_database, 3 | TARGET_OBJECTS AS target_objects, 4 | READ_PRIVATE AS read_private, 5 | WRITE_PRIVATE AS write_private, 6 | EXEC_PRIVATE AS exec_private, 7 | ALLOWED_ORIGINS AS allowed_origins 8 | FROM TEST_GOSQLAPI_TOKENS 9 | WHERE TOKEN = ?token?; -------------------------------------------------------------------------------- /scripts/metadata.sql: -------------------------------------------------------------------------------- 1 | -- @label: metadata 2 | SELECT 3 | !remote_addr! as "REMOTE_ADDRESS", 4 | !host! as "HOST", 5 | !method! as "METHOD", 6 | !path! as "PATH", 7 | !query! as "QUERY", 8 | !user_agent! as "USER_AGENT", 9 | !referer! as "REFERER", 10 | !accept! as "ACCEPT", 11 | !AUThorization! as "AUTHORIZATION" 12 | FROM TEST_GOSQLAPI; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | tmp/ 18 | build/ 19 | 20 | .envrc 21 | run.sh 22 | -------------------------------------------------------------------------------- /test-dockers/Dockerfile.mysql: -------------------------------------------------------------------------------- 1 | FROM mysql:latest 2 | 3 | # Set environment variables for database, user, and password 4 | ENV MYSQL_DATABASE=test_db 5 | ENV MYSQL_USER=test_user 6 | ENV MYSQL_PASSWORD=TestPass123! 7 | ENV MYSQL_ROOT_PASSWORD=RootPass123! 8 | 9 | # No need for a custom CMD; the official image will create the DB and user automatically 10 | 11 | # docker build -f Dockerfile.mysql -t gosqlapi-test-mysql . 12 | # docker run --name gosqlapi-test-mysql -p 13306:3306 -d gosqlapi-test-mysql 13 | -------------------------------------------------------------------------------- /test-dockers/Dockerfile.postgresql: -------------------------------------------------------------------------------- 1 | FROM postgres:latest 2 | 3 | # Set environment variables for database, user, and password 4 | ENV POSTGRES_DB=test_db 5 | ENV POSTGRES_USER=test_user 6 | ENV POSTGRES_PASSWORD=TestPass123! 7 | 8 | 9 | 10 | 11 | # No need for a custom CMD; the official image will create the DB and user automatically 12 | 13 | # docker build -f Dockerfile.postgresql -t gosqlapi-test-postgresql . 14 | # docker run --name gosqlapi-test-postgresql -p 15432:5432 -d gosqlapi-test-postgresql 15 | -------------------------------------------------------------------------------- /test-dockers/Dockerfile.mariadb: -------------------------------------------------------------------------------- 1 | FROM mariadb:latest 2 | 3 | # Set environment variables for database, user, and password 4 | ENV MARIADB_DATABASE=test_db 5 | ENV MARIADB_USER=test_user 6 | ENV MARIADB_PASSWORD=TestPass123! 7 | ENV MARIADB_ROOT_PASSWORD=RootPass123! 8 | 9 | # No need for a custom CMD; the official image will create the DB and user automatically 10 | 11 | # docker build -f Dockerfile.mariadb -t gosqlapi-test-mariadb . 12 | # docker run --name gosqlapi-test-mariadb -p 13307:3306 -d gosqlapi-test-mariadb 13 | -------------------------------------------------------------------------------- /scripts/list_columns_postgres_sqlserver.sql: -------------------------------------------------------------------------------- 1 | SELECT col.*, con.constraint_type FROM INFORMATION_SCHEMA.COLUMNS col 2 | LEFT OUTER JOIN ( 3 | select con.*,usage.column_name from INFORMATION_SCHEMA.TABLE_CONSTRAINTS con 4 | inner join information_schema.key_column_usage usage 5 | on con.table_name = usage.table_name and con.table_schema = usage.table_schema and con.constraint_name = usage.constraint_name 6 | where con.constraint_type='PRIMARY KEY' 7 | ) con on col.TABLE_SCHEMA=con.TABLE_SCHEMA and col.TABLE_NAME=con.TABLE_NAME and col.column_name=con.column_name 8 | WHERE col.TABLE_NAME=?table_name?; -------------------------------------------------------------------------------- /scripts/list_columns_oracle.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | utc.TABLE_NAME, 3 | utc.COLUMN_NAME, 4 | utc.DATA_TYPE, 5 | utc.DATA_LENGTH, 6 | utc.DATA_PRECISION, 7 | utc.DATA_SCALE, 8 | utc.NULLABLE, 9 | case when pk.column_name = utc.COLUMN_NAME then 'Y' else 'N' end as is_pk 10 | FROM user_tab_columns utc 11 | 12 | left outer join ( 13 | SELECT cols.table_name, cols.column_name 14 | FROM all_constraints cons, all_cons_columns cols 15 | WHERE cols.table_name = ?table_name? 16 | AND cons.constraint_type = 'P' 17 | AND cons.constraint_name = cols.constraint_name 18 | AND cons.owner = cols.owner 19 | ORDER BY cols.table_name, cols.position 20 | ) pk on utc.table_name=pk.table_name 21 | and utc.COLUMN_NAME=pk.column_name 22 | 23 | WHERE utc.table_name=?table_name?; -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | declare -a branches=( 4 | "all" 5 | "mysql" 6 | "postgres" 7 | "pgx" 8 | "sqlite" 9 | "sqlite3" 10 | "sqlserver" 11 | "oracle" 12 | ) 13 | 14 | declare -a do_not_merge=( 15 | "go.mod" 16 | "go.sum" 17 | "drivers.go" 18 | ) 19 | 20 | for branch in "${branches[@]}" 21 | do 22 | git checkout "$branch" 23 | git pull origin "$branch" 24 | git merge master --no-ff --no-commit 25 | for file in "${do_not_merge[@]}" 26 | do 27 | git reset HEAD -- "$file" 28 | git checkout -- "$file" 29 | done 30 | ./update.sh 31 | git commit -am "Merge branch 'master' into $branch" 32 | git push origin "$branch" 33 | done 34 | 35 | go mod tidy 36 | git checkout master 37 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | ) 9 | 10 | func init() { 11 | log.SetFlags(log.LstdFlags | log.Lshortfile) 12 | } 13 | 14 | const version = "47" 15 | 16 | func main() { 17 | v := flag.Bool("v", false, "prints version") 18 | confPath := flag.String("c", "gosqlapi.json", "configration file path") 19 | flag.Parse() 20 | if *v { 21 | fmt.Println(version) 22 | os.Exit(0) 23 | } 24 | confBytes, err := os.ReadFile(*confPath) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | app, err := NewApp(confBytes) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | app.run() 33 | 34 | Hook(func() { 35 | app.shutdown() 36 | }) 37 | } 38 | 39 | // Check if anything uses cgo 40 | // go list -f "{{if .CgoFiles}}{{.ImportPath}}{{end}}" $(go list -f "{{.ImportPath}}{{range .Deps}} {{.}}{{end}}") 41 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestExtractSQLParameter(t *testing.T) { 8 | testCases := map[string][]string{ 9 | "?var0? asdf ?var3?": {"var0", "var3"}, 10 | " ?var1? ": {"var1"}, 11 | " ?var2?": {"var2"}, 12 | } 13 | 14 | dbPgx := &Database{Type: "pgx"} 15 | 16 | for k, v := range testCases { 17 | got := dbPgx.ExtractSQLParameters(&k) 18 | if len(got) != len(v) { 19 | t.Errorf(`%v; wanted "%v", got "%v"`, k, len(v), len(got)) 20 | } 21 | for i := range v { 22 | if got[i] != v[i] { 23 | t.Errorf(`%v; wanted "%v", got "%v"`, k, v[i], got[i]) 24 | } 25 | } 26 | } 27 | } 28 | 29 | func TestSplitSqlLabel(t *testing.T) { 30 | testCases := map[string]string{ 31 | "-- @label:insert": "insert", 32 | " --@label: insert ": "insert", 33 | " --@label : insert ": "insert", 34 | } 35 | 36 | for k, v := range testCases { 37 | label, _ := SplitSqlLabel(k) 38 | if label != v { 39 | t.Errorf(`%s; wanted "%s", got "%s"`, k, v, label) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | docker build -t gosqlapi-test-mysql -f ./test-dockers/Dockerfile.mysql . 4 | docker build -t gosqlapi-test-mariadb -f ./test-dockers/Dockerfile.mariadb . 5 | docker build -t gosqlapi-test-postgresql -f ./test-dockers/Dockerfile.postgresql . 6 | docker build -t gosqlapi-test-mssql -f ./test-dockers/Dockerfile.mssql . 7 | 8 | sleep 5 9 | 10 | docker run --name gosqlapi-test-mysql -p 13306:3306 -d gosqlapi-test-mysql 11 | docker run --name gosqlapi-test-mariadb -p 13307:3306 -d gosqlapi-test-mariadb 12 | docker run --name gosqlapi-test-postgresql -p 15432:5432 -d gosqlapi-test-postgresql 13 | docker run --name gosqlapi-test-mssql -p 11433:1433 -d gosqlapi-test-mssql 14 | 15 | sleep 5 16 | 17 | go test 18 | 19 | sleep 5 20 | 21 | docker stop gosqlapi-test-mysql 22 | docker stop gosqlapi-test-mariadb 23 | docker stop gosqlapi-test-postgresql 24 | docker stop gosqlapi-test-mssql 25 | 26 | sleep 5 27 | 28 | docker rm -v gosqlapi-test-mysql 29 | docker rm -v gosqlapi-test-mariadb 30 | docker rm -v gosqlapi-test-postgresql 31 | docker rm -v gosqlapi-test-mssql 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Qian Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/sqlite.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "http_addr": "127.0.0.1:8080", 4 | "cors": true, 5 | "http_headers": { 6 | "abc": "123" 7 | } 8 | }, 9 | "databases": { 10 | "test_db": { 11 | "type": "sqlite", 12 | "url": ":memory:" 13 | } 14 | }, 15 | "scripts": { 16 | "init": { 17 | "database": "test_db", 18 | "path": "scripts/init.sql", 19 | "public_exec": true 20 | }, 21 | "metadata": { 22 | "database": "test_db", 23 | "path": "scripts/metadata.sql" 24 | }, 25 | "list_tables": { 26 | "database": "test_db", 27 | "sql": "SELECT name FROM sqlite_master WHERE type='table' and name like 'TEST_GOSQLAPI%' ORDER BY name", 28 | "public_exec": true 29 | }, 30 | "list_columns": { 31 | "database": "test_db", 32 | "sql": "SELECT * FROM PRAGMA_TABLE_INFO(?table_name?)", 33 | "public_exec": true 34 | } 35 | }, 36 | "tables": { 37 | "test_table": { 38 | "database": "test_db", 39 | "name": "TEST_GOSQLAPI", 40 | "public_read": true, 41 | "public_write": true 42 | }, 43 | "token_table": { 44 | "database": "test_db", 45 | "name": "TEST_GOSQLAPI_TOKENS" 46 | } 47 | }, 48 | "managed_tokens": { 49 | "database": "test_db", 50 | "query_path": "scripts/token_query.sql" 51 | }, 52 | "null_value": "NULL" 53 | } -------------------------------------------------------------------------------- /tests/sqlite3.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "http_addr": "127.0.0.1:8080", 4 | "cors": true, 5 | "http_headers": { 6 | "abc": "123" 7 | } 8 | }, 9 | "databases": { 10 | "test_db": { 11 | "type": "sqlite3", 12 | "url": ":memory:" 13 | } 14 | }, 15 | "scripts": { 16 | "init": { 17 | "database": "test_db", 18 | "path": "scripts/init.sql", 19 | "public_exec": true 20 | }, 21 | "metadata": { 22 | "database": "test_db", 23 | "path": "scripts/metadata.sql" 24 | }, 25 | "list_tables": { 26 | "database": "test_db", 27 | "sql": "SELECT name FROM sqlite_master WHERE type='table' and name like 'TEST_GOSQLAPI%' ORDER BY name", 28 | "public_exec": true 29 | }, 30 | "list_columns": { 31 | "database": "test_db", 32 | "sql": "SELECT * FROM PRAGMA_TABLE_INFO(?table_name?)", 33 | "public_exec": true 34 | } 35 | }, 36 | "tables": { 37 | "test_table": { 38 | "database": "test_db", 39 | "name": "TEST_GOSQLAPI", 40 | "public_read": true, 41 | "public_write": true 42 | }, 43 | "token_table": { 44 | "database": "test_db", 45 | "name": "TEST_GOSQLAPI_TOKENS" 46 | } 47 | }, 48 | "managed_tokens": { 49 | "database": "test_db", 50 | "query_path": "scripts/token_query.sql" 51 | }, 52 | "null_value": "NULL" 53 | } -------------------------------------------------------------------------------- /tests/sqlserver.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "http_addr": "127.0.0.1:8080", 4 | "cors": true, 5 | "http_headers": { 6 | "abc": "123" 7 | } 8 | }, 9 | "databases": { 10 | "test_db": { 11 | "type": "sqlserver", 12 | "url": "env:sqlserver_url" 13 | } 14 | }, 15 | "scripts": { 16 | "init": { 17 | "database": "test_db", 18 | "path": "scripts/init.sql", 19 | "public_exec": true 20 | }, 21 | "metadata": { 22 | "database": "test_db", 23 | "path": "scripts/metadata.sql" 24 | }, 25 | "list_tables": { 26 | "database": "test_db", 27 | "sql": "SELECT name FROM sys.tables WHERE name like 'TEST_GOSQLAPI%' ORDER BY name", 28 | "public_exec": true 29 | }, 30 | "list_columns": { 31 | "database": "test_db", 32 | "path": "scripts/list_columns_postgres_sqlserver.sql", 33 | "public_exec": true 34 | } 35 | }, 36 | "tables": { 37 | "test_table": { 38 | "database": "test_db", 39 | "name": "TEST_GOSQLAPI", 40 | "public_read": true, 41 | "public_write": true 42 | }, 43 | "token_table": { 44 | "database": "test_db", 45 | "name": "TEST_GOSQLAPI_TOKENS" 46 | } 47 | }, 48 | "managed_tokens": { 49 | "database": "test_db", 50 | "query_path": "scripts/token_query.sql" 51 | }, 52 | "cache_tokens": true, 53 | "null_value": "NULL" 54 | } -------------------------------------------------------------------------------- /tests/oracle.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "http_addr": "127.0.0.1:8080", 4 | "cors": true, 5 | "http_headers": { 6 | "abc": "123" 7 | } 8 | }, 9 | "databases": { 10 | "test_db": { 11 | "type": "oracle", 12 | "url": "env:oracle_url" 13 | } 14 | }, 15 | "scripts": { 16 | "init": { 17 | "database": "test_db", 18 | "path": "scripts/init_oracle.sql", 19 | "public_exec": true 20 | }, 21 | "metadata": { 22 | "database": "test_db", 23 | "path": "scripts/metadata.sql" 24 | }, 25 | "list_tables": { 26 | "database": "test_db", 27 | "sql": "SELECT table_name as name FROM user_tables WHERE table_name like 'TEST_GOSQLAPI%' ORDER BY table_name", 28 | "public_exec": true 29 | }, 30 | "list_columns": { 31 | "database": "test_db", 32 | "path": "scripts/list_columns_oracle.sql", 33 | "public_exec": true 34 | } 35 | }, 36 | "tables": { 37 | "test_table": { 38 | "database": "test_db", 39 | "name": "TEST_GOSQLAPI", 40 | "public_read": true, 41 | "public_write": true 42 | }, 43 | "token_table": { 44 | "database": "test_db", 45 | "name": "TEST_GOSQLAPI_TOKENS" 46 | } 47 | }, 48 | "managed_tokens": { 49 | "database": "test_db", 50 | "query_path": "scripts/token_query.sql" 51 | }, 52 | "cache_tokens": true, 53 | "null_value": "NULL" 54 | } -------------------------------------------------------------------------------- /tests/pgx.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "http_addr": "127.0.0.1:8080", 4 | "cors": true, 5 | "http_headers": { 6 | "abc": "123" 7 | } 8 | }, 9 | "databases": { 10 | "test_db": { 11 | "type": "pgx", 12 | "url": "env:pgx_url" 13 | } 14 | }, 15 | "scripts": { 16 | "init": { 17 | "database": "test_db", 18 | "path": "scripts/init.sql", 19 | "public_exec": true 20 | }, 21 | "metadata": { 22 | "database": "test_db", 23 | "path": "scripts/metadata.sql" 24 | }, 25 | "list_tables": { 26 | "database": "test_db", 27 | "sql": "SELECT table_name as NAME FROM information_schema.tables WHERE table_schema = 'public' and table_name like 'test_gosqlapi%' ORDER BY table_name", 28 | "public_exec": true 29 | }, 30 | "list_columns": { 31 | "database": "test_db", 32 | "path": "scripts/list_columns_postgres_sqlserver.sql", 33 | "public_exec": true 34 | } 35 | }, 36 | "tables": { 37 | "test_table": { 38 | "database": "test_db", 39 | "name": "TEST_GOSQLAPI", 40 | "public_read": true, 41 | "public_write": true 42 | }, 43 | "token_table": { 44 | "database": "test_db", 45 | "name": "TEST_GOSQLAPI_TOKENS" 46 | } 47 | }, 48 | "managed_tokens": { 49 | "database": "test_db", 50 | "query_path": "scripts/token_query.sql" 51 | }, 52 | "cache_tokens": true, 53 | "null_value": "NULL" 54 | } -------------------------------------------------------------------------------- /tests/postgres.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "http_addr": "127.0.0.1:8080", 4 | "cors": true, 5 | "http_headers": { 6 | "abc": "123" 7 | } 8 | }, 9 | "databases": { 10 | "test_db": { 11 | "type": "postgres", 12 | "url": "env:postgres_url" 13 | } 14 | }, 15 | "scripts": { 16 | "init": { 17 | "database": "test_db", 18 | "path": "scripts/init.sql", 19 | "public_exec": true 20 | }, 21 | "metadata": { 22 | "database": "test_db", 23 | "path": "scripts/metadata.sql" 24 | }, 25 | "list_tables": { 26 | "database": "test_db", 27 | "sql": "SELECT table_name as NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA='public' and table_name like 'test_gosqlapi%' ORDER BY table_name", 28 | "public_exec": true 29 | }, 30 | "list_columns": { 31 | "database": "test_db", 32 | "path": "scripts/list_columns_postgres_sqlserver.sql", 33 | "public_exec": true 34 | } 35 | }, 36 | "tables": { 37 | "test_table": { 38 | "database": "test_db", 39 | "name": "TEST_GOSQLAPI", 40 | "public_read": true, 41 | "public_write": true 42 | }, 43 | "token_table": { 44 | "database": "test_db", 45 | "name": "TEST_GOSQLAPI_TOKENS" 46 | } 47 | }, 48 | "managed_tokens": { 49 | "database": "test_db", 50 | "query_path": "scripts/token_query.sql" 51 | }, 52 | "cache_tokens": true, 53 | "null_value": "NULL" 54 | } -------------------------------------------------------------------------------- /gosqlapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "http_addr": "127.0.0.1:8080", 4 | "cors": true, 5 | "http_headers": { 6 | "abc": "123" 7 | } 8 | }, 9 | "databases": { 10 | "test_db": { 11 | "type": "sqlite", 12 | "url": ":memory:" 13 | } 14 | }, 15 | "scripts": { 16 | "init": { 17 | "database": "test_db", 18 | "path": "scripts/init.sql", 19 | "public_exec": true 20 | }, 21 | "metadata": { 22 | "database": "test_db", 23 | "path": "scripts/metadata.sql" 24 | }, 25 | "list_tables": { 26 | "database": "test_db", 27 | "sql": "SELECT name FROM sqlite_master WHERE type='table' and name like 'TEST_GOSQLAPI%' ORDER BY name", 28 | "public_exec": true 29 | }, 30 | "list_columns": { 31 | "database": "test_db", 32 | "sql": "SELECT * FROM PRAGMA_TABLE_INFO(?table_name?)", 33 | "public_exec": true 34 | } 35 | }, 36 | "tables": { 37 | "test_table": { 38 | "database": "test_db", 39 | "name": "TEST_GOSQLAPI", 40 | "exported_columns": [ 41 | "NAME" 42 | ], 43 | "public_read": true, 44 | "public_write": true 45 | }, 46 | "token_table": { 47 | "database": "test_db", 48 | "name": "TEST_GOSQLAPI_TOKENS", 49 | "show_total": true 50 | } 51 | }, 52 | "managed_tokens": { 53 | "database": "test_db", 54 | "query_path": "scripts/token_query.sql" 55 | }, 56 | "null_value": "NULL" 57 | } -------------------------------------------------------------------------------- /test-dockers/Dockerfile.mssql: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/mssql/server:2022-latest 2 | 3 | 4 | ENV ACCEPT_EULA=Y 5 | # MSSQL SA password 6 | ENV MSSQL_SA_PASSWORD=TestPass123! 7 | # Database name 8 | ENV MSSQL_DB=test_db 9 | # SQL statement to create the database 10 | ENV MSSQL_CREATE_DB_STMT="CREATE DATABASE $MSSQL_DB;" 11 | 12 | 13 | # Switch to root to install tools 14 | USER root 15 | RUN apt-get update \ 16 | && apt-get install -y curl apt-transport-https gnupg2 ca-certificates \ 17 | && mkdir -p /etc/apt/keyrings \ 18 | && curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor > /etc/apt/keyrings/microsoft.gpg \ 19 | && chmod go+r /etc/apt/keyrings/microsoft.gpg \ 20 | && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/microsoft.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" > /etc/apt/sources.list.d/mssql-release.list \ 21 | && apt-get update \ 22 | && ACCEPT_EULA=Y apt-get install -y msodbcsql18 mssql-tools18 \ 23 | && apt-get clean \ 24 | && rm -rf /var/lib/apt/lists/* 25 | ENV PATH="$PATH:/opt/mssql-tools18/bin" 26 | 27 | # Switch back to mssql user 28 | USER mssql 29 | 30 | CMD /opt/mssql/bin/sqlservr & \ 31 | sleep 20 && \ 32 | echo "$MSSQL_CREATE_DB_STMT" | /opt/mssql-tools18/bin/sqlcmd -S localhost -U SA -P "$MSSQL_SA_PASSWORD" -C && \ 33 | wait 34 | 35 | 36 | # docker build -f Dockerfile.mssql -t gosqlapi-test-mssql . 37 | # docker run --name gosqlapi-test-mssql -p 11433:1433 -d gosqlapi-test-mssql -------------------------------------------------------------------------------- /tests/mysql.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "http_addr": "127.0.0.1:8080", 4 | "cors": true, 5 | "http_headers": { 6 | "abc": "123" 7 | } 8 | }, 9 | "databases": { 10 | "test_db": { 11 | "type": "mysql", 12 | "url": "env:mysql_url" 13 | } 14 | }, 15 | "scripts": { 16 | "init": { 17 | "database": "test_db", 18 | "path": "scripts/init.sql", 19 | "public_exec": true 20 | }, 21 | "metadata": { 22 | "database": "test_db", 23 | "path": "scripts/metadata.sql" 24 | }, 25 | "list_tables": { 26 | "database": "test_db", 27 | "sql": "SELECT table_name as name FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA=DATABASE() and table_name like 'TEST_GOSQLAPI%' ORDER BY table_name", 28 | "public_exec": true 29 | }, 30 | "list_columns": { 31 | "database": "test_db", 32 | "sql": "SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?table_name?", 33 | "public_exec": true 34 | } 35 | }, 36 | "tables": { 37 | "test_table": { 38 | "database": "test_db", 39 | "name": "TEST_GOSQLAPI", 40 | "public_read": true, 41 | "public_write": true 42 | }, 43 | "token_table": { 44 | "database": "test_db", 45 | "name": "TEST_GOSQLAPI_TOKENS" 46 | } 47 | }, 48 | "managed_tokens": { 49 | "database": "test_db", 50 | "query_path": "scripts/token_query.sql" 51 | }, 52 | "cache_tokens": true, 53 | "null_value": "NULL" 54 | } -------------------------------------------------------------------------------- /tests/mariadb.json: -------------------------------------------------------------------------------- 1 | { 2 | "web": { 3 | "http_addr": "127.0.0.1:8080", 4 | "cors": true, 5 | "http_headers": { 6 | "abc": "123" 7 | } 8 | }, 9 | "databases": { 10 | "test_db": { 11 | "type": "mysql", 12 | "url": "env:mariadb_url" 13 | } 14 | }, 15 | "scripts": { 16 | "init": { 17 | "database": "test_db", 18 | "path": "scripts/init.sql", 19 | "public_exec": true 20 | }, 21 | "metadata": { 22 | "database": "test_db", 23 | "path": "scripts/metadata.sql" 24 | }, 25 | "list_tables": { 26 | "database": "test_db", 27 | "sql": "SELECT table_name as name FROM information_schema.tables WHERE table_schema = DATABASE() and table_name like 'TEST_GOSQLAPI%' ORDER BY table_name", 28 | "public_exec": true 29 | }, 30 | "list_columns": { 31 | "database": "test_db", 32 | "sql": "SELECT * FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?table_name?", 33 | "public_exec": true 34 | } 35 | }, 36 | "tables": { 37 | "test_table": { 38 | "database": "test_db", 39 | "name": "TEST_GOSQLAPI", 40 | "public_read": true, 41 | "public_write": true 42 | }, 43 | "token_table": { 44 | "database": "test_db", 45 | "name": "TEST_GOSQLAPI_TOKENS" 46 | } 47 | }, 48 | "managed_tokens": { 49 | "database": "test_db", 50 | "query_path": "scripts/token_query.sql" 51 | }, 52 | "cache_tokens": true, 53 | "null_value": "NULL" 54 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/elgs/gosqlapi 2 | 3 | go 1.25.5 4 | 5 | // replace github.com/elgs/gosplitargs => ../gosplitargs 6 | // replace github.com/elgs/gosqlcrud => ../gosqlcrud 7 | 8 | require ( 9 | github.com/elgs/gosplitargs v0.0.0-20241205072753-cbd889c0f906 10 | github.com/elgs/gosqlcrud v0.0.0-20250910094801-55167c4527dc 11 | github.com/go-sql-driver/mysql v1.9.3 12 | github.com/jackc/pgx/v5 v5.7.6 13 | github.com/lib/pq v1.10.9 14 | github.com/microsoft/go-mssqldb v1.9.5 15 | github.com/sijms/go-ora/v2 v2.9.0 16 | github.com/stretchr/testify v1.11.1 17 | modernc.org/sqlite v1.40.1 18 | ) 19 | 20 | require ( 21 | filippo.io/edwards25519 v1.1.0 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/dustin/go-humanize v1.0.1 // indirect 24 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect 25 | github.com/golang-sql/sqlexp v0.1.0 // indirect 26 | github.com/google/uuid v1.6.0 // indirect 27 | github.com/jackc/pgpassfile v1.0.0 // indirect 28 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 29 | github.com/jackc/puddle/v2 v2.2.2 // indirect 30 | github.com/kr/pretty v0.3.1 // indirect 31 | github.com/mattn/go-isatty v0.0.20 // indirect 32 | github.com/ncruces/go-strftime v1.0.0 // indirect 33 | github.com/pmezard/go-difflib v1.0.0 // indirect 34 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 35 | github.com/rogpeppe/go-internal v1.12.0 // indirect 36 | github.com/shopspring/decimal v1.4.0 // indirect 37 | golang.org/x/crypto v0.46.0 // indirect 38 | golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect 39 | golang.org/x/net v0.48.0 // indirect 40 | golang.org/x/sync v0.19.0 // indirect 41 | golang.org/x/sys v0.39.0 // indirect 42 | golang.org/x/text v0.32.0 // indirect 43 | gopkg.in/yaml.v3 v3.0.1 // indirect 44 | modernc.org/libc v1.67.1 // indirect 45 | modernc.org/mathutil v1.7.1 // indirect 46 | modernc.org/memory v1.11.0 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /scripts/init_oracle.sql: -------------------------------------------------------------------------------- 1 | drop TABLE TEST_GOSQLAPI; 2 | drop TABLE TEST_GOSQLAPI_TOKENS; 3 | 4 | create TABLE TEST_GOSQLAPI ( 5 | ID INTEGER NOT NULL PRIMARY KEY, 6 | NAME VARCHAR(50) 7 | ); 8 | 9 | insert INTO TEST_GOSQLAPI (ID, NAME) VALUES (1, 'Alpha'); 10 | 11 | insert INTO TEST_GOSQLAPI (ID, NAME) VALUES (2, 'Beta'); 12 | 13 | insert INTO TEST_GOSQLAPI (ID, NAME) VALUES (3, 'Gamma'); 14 | 15 | 16 | -- @label: data 17 | SELECT * FROM TEST_GOSQLAPI WHERE ID > ?low? AND ID < ?high?; 18 | 19 | create TABLE TEST_GOSQLAPI_TOKENS ( 20 | ID INTEGER NOT NULL PRIMARY KEY, 21 | TOKEN VARCHAR(255) NOT NULL, 22 | TARGET_DATABASE VARCHAR(255) NOT NULL, 23 | TARGET_OBJECTS VARCHAR(255) NOT NULL, 24 | READ_PRIVATE INT NOT NULL, 25 | WRITE_PRIVATE INT NOT NULL, 26 | EXEC_PRIVATE INT NOT NULL, 27 | ALLOWED_ORIGINS VARCHAR(1000) NOT NULL 28 | ); 29 | create INDEX TOKEN_INDEX ON TEST_GOSQLAPI_TOKENS (TOKEN); 30 | 31 | insert INTO 32 | TEST_GOSQLAPI_TOKENS (ID, TOKEN, TARGET_DATABASE, TARGET_OBJECTS, READ_PRIVATE, WRITE_PRIVATE, EXEC_PRIVATE, ALLOWED_ORIGINS) 33 | VALUES (1, '1234567890', 'test_db', 'token_table', 1, 0, 0, 'localhost'); 34 | 35 | insert INTO 36 | TEST_GOSQLAPI_TOKENS (ID, TOKEN, TARGET_DATABASE, TARGET_OBJECTS, READ_PRIVATE, WRITE_PRIVATE, EXEC_PRIVATE, ALLOWED_ORIGINS) 37 | VALUES (2, '0987654321', 'test_db', 'metadata', 0, 0, 1, 'localhost *.example.com'); 38 | 39 | insert INTO 40 | TEST_GOSQLAPI_TOKENS (ID, TOKEN, TARGET_DATABASE, TARGET_OBJECTS, READ_PRIVATE, WRITE_PRIVATE, EXEC_PRIVATE, ALLOWED_ORIGINS) 41 | VALUES (3, 'no_access', 'test_db', '*', 1, 1, 1, ' '); 42 | 43 | insert INTO 44 | TEST_GOSQLAPI_TOKENS (ID, TOKEN, TARGET_DATABASE, TARGET_OBJECTS, READ_PRIVATE, WRITE_PRIVATE, EXEC_PRIVATE, ALLOWED_ORIGINS) 45 | VALUES (4, 'super', 'test_db', '*', 1, 1, 1, '*'); -------------------------------------------------------------------------------- /scripts/init.sql: -------------------------------------------------------------------------------- 1 | drop TABLE IF EXISTS TEST_GOSQLAPI; 2 | drop TABLE IF EXISTS TEST_GOSQLAPI_TOKENS; 3 | 4 | create TABLE TEST_GOSQLAPI ( 5 | ID INTEGER NOT NULL PRIMARY KEY, 6 | NAME VARCHAR(50) 7 | ); 8 | 9 | insert INTO TEST_GOSQLAPI (ID, NAME) VALUES (1, 'Alpha'); 10 | 11 | insert INTO TEST_GOSQLAPI (ID, NAME) VALUES (2, 'Beta'); 12 | 13 | insert INTO TEST_GOSQLAPI (ID, NAME) VALUES (3, 'Gamma'); 14 | 15 | 16 | -- @label: data 17 | SELECT * FROM TEST_GOSQLAPI WHERE ID > ?low? AND ID < ?high?; 18 | 19 | create TABLE TEST_GOSQLAPI_TOKENS ( 20 | ID INTEGER NOT NULL PRIMARY KEY, 21 | TOKEN VARCHAR(255) NOT NULL, 22 | TARGET_DATABASE VARCHAR(255) NOT NULL, 23 | TARGET_OBJECTS VARCHAR(255) NOT NULL, 24 | READ_PRIVATE INT NOT NULL, 25 | WRITE_PRIVATE INT NOT NULL, 26 | EXEC_PRIVATE INT NOT NULL, 27 | ALLOWED_ORIGINS VARCHAR(1000) NOT NULL 28 | ); 29 | create INDEX TOKEN_INDEX ON TEST_GOSQLAPI_TOKENS (TOKEN); 30 | 31 | insert INTO 32 | TEST_GOSQLAPI_TOKENS (ID, TOKEN, TARGET_DATABASE, TARGET_OBJECTS, READ_PRIVATE, WRITE_PRIVATE, EXEC_PRIVATE, ALLOWED_ORIGINS) 33 | VALUES (1, '1234567890', 'test_db', 'token_table', 1, 0, 0, 'localhost'); 34 | 35 | insert INTO 36 | TEST_GOSQLAPI_TOKENS (ID, TOKEN, TARGET_DATABASE, TARGET_OBJECTS, READ_PRIVATE, WRITE_PRIVATE, EXEC_PRIVATE, ALLOWED_ORIGINS) 37 | VALUES (2, '0987654321', 'test_db', 'metadata', 0, 0, 1, 'localhost *.example.com'); 38 | 39 | insert INTO 40 | TEST_GOSQLAPI_TOKENS (ID, TOKEN, TARGET_DATABASE, TARGET_OBJECTS, READ_PRIVATE, WRITE_PRIVATE, EXEC_PRIVATE, ALLOWED_ORIGINS) 41 | VALUES (3, 'no_access', 'test_db', '*', 1, 1, 1, ''); 42 | 43 | insert INTO 44 | TEST_GOSQLAPI_TOKENS (ID, TOKEN, TARGET_DATABASE, TARGET_OBJECTS, READ_PRIVATE, WRITE_PRIVATE, EXEC_PRIVATE, ALLOWED_ORIGINS) 45 | VALUES (4, 'super', 'test_db', '*', 1, 1, 1, '*'); -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "net/http" 6 | 7 | "github.com/elgs/gosqlcrud" 8 | ) 9 | 10 | type App struct { 11 | Web *Web `json:"web"` 12 | Databases map[string]*Database `json:"databases"` 13 | Scripts map[string]*Script `json:"scripts"` 14 | Tables map[string]*Table `json:"tables"` 15 | Tokens map[string][]*Access `json:"tokens"` 16 | ManagedTokens *ManagedTokens `json:"managed_tokens"` 17 | CacheTokens bool `json:"cache_tokens"` 18 | NullValue any `json:"null_value"` 19 | tokenCache map[string][]*Access 20 | } 21 | 22 | type Web struct { 23 | HttpAddr string `json:"http_addr"` 24 | HttpsAddr string `json:"https_addr"` 25 | CertFile string `json:"cert_file"` 26 | KeyFile string `json:"key_file"` 27 | Cors bool `json:"cors"` 28 | HttpHeaders map[string]string `json:"http_headers"` 29 | httpServer *http.Server 30 | httpsServer *http.Server 31 | } 32 | 33 | type Database struct { 34 | Type string `json:"type"` 35 | Url string `json:"url"` 36 | dbType gosqlcrud.DbType 37 | conn *sql.DB 38 | } 39 | 40 | type Access struct { 41 | TargetDatabase string `json:"target_database" db:"target_database"` 42 | TargetObjectArray []string `json:"target_objects"` 43 | TargetObjects string `db:"target_objects"` 44 | ReadPrivate bool `json:"read_private" db:"read_private"` 45 | WritePrivate bool `json:"write_private" db:"write_private"` 46 | ExecPrivate bool `json:"exec_private" db:"exec_private"` 47 | AllowedOriginArray []string `json:"allowed_origins"` 48 | AllowedOrigins string `db:"allowed_origins"` 49 | } 50 | 51 | type ManagedTokens struct { 52 | Database string `json:"database"` 53 | TableName string `json:"table_name"` 54 | Query string `json:"query"` 55 | QueryPath string `json:"query_path"` 56 | Token string `json:"token"` 57 | TargetDatabase string `json:"target_database"` 58 | TargetObjects string `json:"target_objects"` 59 | ReadPrivate string `json:"read_private"` 60 | WritePrivate string `json:"write_private"` 61 | ExecPrivate string `json:"exec_private"` 62 | AllowedOrigins string `json:"allowed_origins"` 63 | } 64 | 65 | type Statement struct { 66 | Label string 67 | SQL string 68 | Params []string 69 | Query bool 70 | Export bool 71 | Script *Script 72 | } 73 | 74 | type Script struct { 75 | Database string `json:"database"` 76 | SQL string `json:"sql"` 77 | Path string `json:"path"` 78 | PublicExec bool `json:"public_exec"` 79 | Statements []*Statement 80 | built bool 81 | } 82 | 83 | type Table struct { 84 | Database string `json:"database"` 85 | Name string `json:"name"` 86 | PrimaryKey string `json:"primary_key"` // default to "ID" 87 | ExportedColumns []string `json:"exported_columns"` // empty means all 88 | PublicRead bool `json:"public_read"` 89 | PublicWrite bool `json:"public_write"` 90 | PageSize int `json:"page_size"` 91 | OrderBy string `json:"order_by"` 92 | ShowTotal bool `json:"show_total"` 93 | } 94 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "regexp" 9 | "strings" 10 | "syscall" 11 | "unicode" 12 | 13 | "github.com/elgs/gosqlcrud" 14 | ) 15 | 16 | // valuesToMap - convert url.Values to map[string]any 17 | func valuesToMap(keyLowerCase bool, nullValue any, values ...map[string][]string) map[string]any { 18 | ret := map[string]any{} 19 | for _, vs := range values { 20 | for k, v := range vs { 21 | var value any 22 | if len(v) == 0 { 23 | value = nil 24 | } else if len(v) >= 1 { 25 | value = v[0] 26 | if value == nullValue { 27 | value = nil 28 | } 29 | } 30 | if keyLowerCase { 31 | ret[strings.ToLower(k)] = value 32 | } else { 33 | ret[k] = value 34 | } 35 | } 36 | } 37 | return ret 38 | } 39 | 40 | func Hook(clean func()) { 41 | sigs := make(chan os.Signal, 1) 42 | done := make(chan bool, 1) 43 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) 44 | 45 | go func() { 46 | <-sigs 47 | if clean != nil { 48 | clean() 49 | } 50 | done <- true 51 | }() 52 | <-done 53 | } 54 | 55 | func SqlNormalize(sql *string) { 56 | *sql = strings.TrimSpace(*sql) 57 | var ret string 58 | lines := strings.Split(*sql, "\n") 59 | for _, line := range lines { 60 | lineTrimmed := strings.TrimSpace(line) 61 | if lineTrimmed != "" && !strings.HasPrefix(lineTrimmed, "--") { 62 | ret += line + "\n" 63 | } 64 | } 65 | *sql = ret 66 | } 67 | 68 | func SplitSqlLabel(sqlString string) (label string, s string) { 69 | sqlString = strings.TrimSpace(sqlString) + "\n" 70 | labelAndSql := strings.SplitN(sqlString, "\n", 2) 71 | labelPart := labelAndSql[0] 72 | sqlPart := labelAndSql[1] 73 | r := regexp.MustCompile(`(?i)^\-\-\s*@label\s*\:\s*(.+)\s*`) 74 | m := r.FindStringSubmatch(labelPart) 75 | if len(m) >= 2 { 76 | SqlNormalize(&sqlPart) 77 | return strings.TrimSpace(m[1]), strings.TrimSpace(sqlPart) 78 | } 79 | SqlNormalize(&sqlString) 80 | return "", strings.TrimSpace(sqlString) 81 | } 82 | 83 | func ReplaceRequestParameters(s *string, r *http.Request) { 84 | regex := regexp.MustCompile(`\!(.+?)\!`) 85 | m := regex.FindAllStringSubmatch(*s, -1) 86 | for _, v := range m { 87 | if len(v) >= 2 { 88 | replacement := GetMetaDataFromRequest(v[1], r) 89 | gosqlcrud.SqlSafe(&replacement) 90 | *s = strings.ReplaceAll(*s, v[0], fmt.Sprintf("'%s'", replacement)) 91 | } 92 | } 93 | } 94 | 95 | func IsQuery(sql string) bool { 96 | sqlUpper := strings.ToUpper(strings.TrimSpace(sql)) 97 | return strings.HasPrefix(sqlUpper, "SELECT") || 98 | strings.HasPrefix(sqlUpper, "SHOW") || 99 | strings.HasPrefix(sqlUpper, "DESCRIBE") || 100 | strings.HasPrefix(sqlUpper, "EXPLAIN") || 101 | strings.HasPrefix(sqlUpper, "PRAGMA") || 102 | strings.HasPrefix(sqlUpper, "WITH") 103 | } 104 | 105 | func ShouldExport(sql string) bool { 106 | if len(sql) == 0 { 107 | return false 108 | } 109 | if !unicode.IsLetter([]rune(sql)[0]) { 110 | return false 111 | } 112 | return strings.ToUpper(sql[0:1]) == sql[0:1] 113 | } 114 | 115 | func ExtractIPAddressFromHost(host string) string { 116 | sepIndex := strings.LastIndex(host, ":") 117 | ip := host[0:sepIndex] 118 | ip = strings.ReplaceAll(strings.ReplaceAll(ip, "[", ""), "]", "") 119 | return ip 120 | } 121 | 122 | func GetMetaDataFromRequest(key string, r *http.Request) string { 123 | if key == "host" { 124 | return r.Host 125 | } else if key == "remote_addr" { 126 | return ExtractIPAddressFromHost(r.RemoteAddr) 127 | } else if key == "method" { 128 | return r.Method 129 | } else if key == "path" { 130 | return r.URL.Path 131 | } else if key == "query" { 132 | return r.URL.RawQuery 133 | } else if key == "user_agent" { 134 | return r.UserAgent() 135 | } else if key == "referer" { 136 | return r.Referer() 137 | } 138 | return r.Header.Get(key) 139 | } 140 | 141 | func ArrayOfStructsToArrayOfPointersOfStructs[T any](a []T) []*T { 142 | b := make([]*T, len(a)) 143 | for i := range a { 144 | b[i] = &a[i] 145 | } 146 | return b 147 | } 148 | 149 | func Contains[T comparable](s []T, e T) bool { 150 | for _, v := range s { 151 | if v == e { 152 | return true 153 | } 154 | } 155 | return false 156 | } 157 | -------------------------------------------------------------------------------- /api_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "math/rand" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/suite" 16 | ) 17 | 18 | var count = 0 19 | 20 | type APITestSuite struct { 21 | suite.Suite 22 | baseURL string 23 | serverAddr string 24 | config string 25 | app *App 26 | } 27 | 28 | func TestAPITestSuite(t *testing.T) { 29 | configs := []string{ 30 | "./gosqlapi.json", 31 | "./tests/mysql.json", 32 | "./tests/mariadb.json", 33 | "./tests/pgx.json", 34 | "./tests/postgres.json", 35 | "./tests/oracle.json", 36 | "./tests/sqlite.json", 37 | "./tests/sqlserver.json", 38 | // "./tests/sqlite3.json", // need to checkout sqlite3 branch 39 | } 40 | 41 | fmt.Println("Starting API tests version:", version) 42 | for index, config := range configs { 43 | testAll := os.Getenv("test_all") 44 | if index > 0 && testAll == "" { 45 | continue 46 | } 47 | port := rand.Intn(10000) + 40000 48 | suite.Run(t, &APITestSuite{ 49 | baseURL: fmt.Sprintf("http://127.0.0.1:%v/", port), 50 | serverAddr: fmt.Sprintf("127.0.0.1:%v", port), 51 | config: config, 52 | }) 53 | } 54 | } 55 | 56 | func (this *APITestSuite) SetupSuite() { 57 | confBytes, err := os.ReadFile(this.config) 58 | this.Nil(err) 59 | this.app, err = NewApp(confBytes) 60 | this.Nil(err) 61 | this.app.Web.HttpAddr = this.serverAddr 62 | go this.app.run() 63 | time.Sleep(time.Second * 5) 64 | } 65 | 66 | func (this *APITestSuite) TearDownSuite() { 67 | } 68 | 69 | func (this *APITestSuite) TestAPI() { 70 | this.testAPI(false) 71 | this.testAPI(true) 72 | } 73 | 74 | func (this *APITestSuite) testAPI(usePatch bool) { 75 | count++ 76 | fmt.Println("+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++", count) 77 | scriptMethod := "PATCH" 78 | if !usePatch { 79 | scriptMethod = "GET" 80 | } 81 | fmt.Println("Testing API with config:", this.config, "with usePatch:", usePatch) 82 | // patch init 83 | req, err := http.NewRequest(scriptMethod, this.baseURL+"test_db/init/", bytes.NewBuffer([]byte(`{"low": 0,"high": 3}`))) 84 | this.Nil(err) 85 | req.Header.Set("Content-Type", "application/json") 86 | client := &http.Client{} 87 | resp, err := client.Do(req) 88 | this.Nil(err) 89 | defer resp.Body.Close() 90 | this.Assert().Equal("123", resp.Header.Get("abc")) 91 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 92 | body, err := io.ReadAll(resp.Body) 93 | this.Nil(err) 94 | var respBody map[string]any 95 | err = json.Unmarshal(body, &respBody) 96 | this.Nil(err) 97 | this.Assert().Equal(2, len(respBody["data"].([]any))) 98 | this.Assert().Equal(1, int(respBody["data"].([]any)[0].(map[string]any)["id"].(float64))) 99 | this.Assert().Equal("Alpha", respBody["data"].([]any)[0].(map[string]any)["name"].(string)) 100 | this.Assert().Equal(2, int(respBody["data"].([]any)[1].(map[string]any)["id"].(float64))) 101 | this.Assert().Equal("Beta", respBody["data"].([]any)[1].(map[string]any)["name"].(string)) 102 | 103 | // get 104 | resp, err = http.Get(this.baseURL + "test_db/test_table/1") 105 | this.Nil(err) 106 | defer resp.Body.Close() 107 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 108 | body, err = io.ReadAll(resp.Body) 109 | this.Nil(err) 110 | var respBody2 map[string]any 111 | err = json.Unmarshal(body, &respBody2) 112 | this.Nil(err) 113 | this.Assert().Equal(1, int(respBody2["id"].(float64))) 114 | this.Assert().Equal("Alpha", respBody2["name"].(string)) 115 | 116 | // post 117 | req, err = http.NewRequest("POST", this.baseURL+"test_db/test_table/", bytes.NewBuffer([]byte(`{"id": 4,"name": "Gamma"}`))) 118 | this.Nil(err) 119 | req.Header.Set("Content-Type", "application/json") 120 | resp, err = client.Do(req) 121 | this.Nil(err) 122 | defer resp.Body.Close() 123 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 124 | body, err = io.ReadAll(resp.Body) 125 | this.Nil(err) 126 | var respBody3 map[string]any 127 | err = json.Unmarshal(body, &respBody3) 128 | this.Nil(err) 129 | this.Assert().Equal(1, int(respBody3["rows_affected"].(float64))) 130 | 131 | // insert null 132 | req, err = http.NewRequest("POST", this.baseURL+"test_db/test_table/?id=5&name=NULL", nil) 133 | this.Nil(err) 134 | req.Header.Set("Content-Type", "application/json") 135 | resp, err = client.Do(req) 136 | this.Nil(err) 137 | defer resp.Body.Close() 138 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 139 | body, err = io.ReadAll(resp.Body) 140 | this.Nil(err) 141 | var respBodyInsertNull map[string]any 142 | err = json.Unmarshal(body, &respBodyInsertNull) 143 | this.Nil(err) 144 | this.Assert().Equal(1, int(respBodyInsertNull["rows_affected"].(float64))) 145 | 146 | // test null 147 | resp, err = http.Get(this.baseURL + "test_db/test_table/5") 148 | this.Nil(err) 149 | defer resp.Body.Close() 150 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 151 | body, err = io.ReadAll(resp.Body) 152 | this.Nil(err) 153 | var respBodyTestNull map[string]any 154 | err = json.Unmarshal(body, &respBodyTestNull) 155 | this.Nil(err) 156 | this.Assert().Equal(5, int(respBodyTestNull["id"].(float64))) 157 | this.Assert().Equal(nil, respBodyTestNull["name"]) 158 | 159 | // delete null 160 | req, err = http.NewRequest("DELETE", this.baseURL+"test_db/test_table/5", nil) 161 | this.Nil(err) 162 | resp, err = client.Do(req) 163 | this.Nil(err) 164 | defer resp.Body.Close() 165 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 166 | body, err = io.ReadAll(resp.Body) 167 | this.Nil(err) 168 | var respBodyDeleteNull map[string]any 169 | err = json.Unmarshal(body, &respBodyDeleteNull) 170 | this.Nil(err) 171 | this.Assert().Equal(1, int(respBodyDeleteNull["rows_affected"].(float64))) 172 | 173 | // put 174 | req, err = http.NewRequest("PUT", this.baseURL+"test_db/test_table/4", bytes.NewBuffer([]byte(`{"name": "Omega"}`))) 175 | this.Nil(err) 176 | req.Header.Set("Content-Type", "application/json") 177 | resp, err = client.Do(req) 178 | this.Nil(err) 179 | defer resp.Body.Close() 180 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 181 | body, err = io.ReadAll(resp.Body) 182 | this.Nil(err) 183 | var respBody4 map[string]any 184 | err = json.Unmarshal(body, &respBody4) 185 | this.Nil(err) 186 | this.Assert().Equal(1, int(respBody4["rows_affected"].(float64))) 187 | 188 | // delete 189 | req, err = http.NewRequest("DELETE", this.baseURL+"test_db/test_table/4", nil) 190 | this.Nil(err) 191 | resp, err = client.Do(req) 192 | this.Nil(err) 193 | defer resp.Body.Close() 194 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 195 | body, err = io.ReadAll(resp.Body) 196 | this.Nil(err) 197 | var respBody5 map[string]any 198 | err = json.Unmarshal(body, &respBody5) 199 | this.Nil(err) 200 | this.Assert().Equal(1, int(respBody5["rows_affected"].(float64))) 201 | 202 | // get page 203 | resp, err = http.Get(this.baseURL + "test_db/test_table/?.page_size=2&.offset=1&.show_total=1") 204 | this.Nil(err) 205 | defer resp.Body.Close() 206 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 207 | body, err = io.ReadAll(resp.Body) 208 | this.Nil(err) 209 | var respBody6 map[string]any 210 | err = json.Unmarshal(body, &respBody6) 211 | this.Nil(err) 212 | this.Assert().Equal(3, int(respBody6["total"].(float64))) 213 | this.Assert().Equal(1, int(respBody6["offset"].(float64))) 214 | this.Assert().Equal(2, int(respBody6["page_size"].(float64))) 215 | this.Assert().Equal(2, len(respBody6["data"].([]any))) 216 | this.Assert().Equal("Beta", respBody6["data"].([]any)[0].(map[string]any)["name"].(string)) 217 | this.Assert().Equal("Gamma", respBody6["data"].([]any)[1].(map[string]any)["name"].(string)) 218 | // get without auth token and get 401 219 | resp, err = http.Get(this.baseURL + "test_db/token_table/") 220 | this.Nil(err) 221 | defer resp.Body.Close() 222 | this.Assert().Equal(http.StatusUnauthorized, resp.StatusCode) 223 | 224 | // get with bad auth token and get 401 225 | req, err = http.NewRequest("GET", this.baseURL+"test_db/token_table/", nil) 226 | this.Nil(err) 227 | req.Header.Set("authorization", "bad_token") 228 | resp, err = client.Do(req) 229 | this.Nil(err) 230 | defer resp.Body.Close() 231 | this.Assert().Equal(http.StatusUnauthorized, resp.StatusCode) 232 | // get with auth token and get 200 233 | req, err = http.NewRequest("GET", this.baseURL+"test_db/token_table/", nil) 234 | req.Header.Set("Origin", "http://localhost:8080") 235 | this.Nil(err) 236 | req.Header.Set("authorization", "1234567890") 237 | resp, err = client.Do(req) 238 | this.Nil(err) 239 | defer resp.Body.Close() 240 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 241 | // get with auth token but no origin and get 401 242 | req, err = http.NewRequest("GET", this.baseURL+"test_db/token_table/", nil) 243 | this.Nil(err) 244 | req.Header.Set("authorization", "1234567890") 245 | resp, err = client.Do(req) 246 | this.Nil(err) 247 | defer resp.Body.Close() 248 | this.Assert().Equal(http.StatusUnauthorized, resp.StatusCode) 249 | // get with auth token with no origin and get 401 250 | req, err = http.NewRequest("GET", this.baseURL+"test_db/token_table/", nil) 251 | this.Nil(err) 252 | req.Header.Set("authorization", "no_access") 253 | resp, err = client.Do(req) 254 | this.Nil(err) 255 | defer resp.Body.Close() 256 | this.Assert().Equal(http.StatusUnauthorized, resp.StatusCode) 257 | // get with auth token with all origin access and get 200 258 | req, err = http.NewRequest("GET", this.baseURL+"test_db/token_table/", nil) 259 | this.Nil(err) 260 | req.Header.Set("authorization", "super") 261 | resp, err = client.Do(req) 262 | this.Nil(err) 263 | defer resp.Body.Close() 264 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 265 | 266 | // query metadata 267 | req, err = http.NewRequest(scriptMethod, this.baseURL+"test_db/metadata/", nil) 268 | req.Header.Set("Origin", "https://*.example.com") 269 | this.Nil(err) 270 | req.Header.Set("authorization", "Bearer 0987654321") 271 | resp, err = client.Do(req) 272 | this.Nil(err) 273 | defer resp.Body.Close() 274 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 275 | body, err = io.ReadAll(resp.Body) 276 | this.Nil(err) 277 | var respBody7 map[string]any 278 | err = json.Unmarshal(body, &respBody7) 279 | this.Nil(err) 280 | this.Assert().Equal("Bearer 0987654321", respBody7["metadata"].([]any)[0].(map[string]any)["authorization"].(string)) 281 | // query metadata with wrong referer 282 | req, err = http.NewRequest(scriptMethod, this.baseURL+"test_db/metadata/", nil) 283 | req.Header.Set("Origin", "https://*.example.net") 284 | this.Nil(err) 285 | req.Header.Set("authorization", "Bearer 0987654321") 286 | resp, err = client.Do(req) 287 | this.Nil(err) 288 | defer resp.Body.Close() 289 | this.Assert().Equal(http.StatusUnauthorized, resp.StatusCode) 290 | 291 | // query tables 292 | req, err = http.NewRequest(scriptMethod, this.baseURL+"test_db/list_tables/", nil) 293 | this.Nil(err) 294 | resp, err = client.Do(req) 295 | this.Nil(err) 296 | defer resp.Body.Close() 297 | this.Assert().Equal(http.StatusOK, resp.StatusCode) 298 | body, err = io.ReadAll(resp.Body) 299 | this.Nil(err) 300 | var respBody8 []any 301 | err = json.Unmarshal(body, &respBody8) 302 | this.Nil(err) 303 | this.Assert().Equal("TEST_GOSQLAPI", strings.ToUpper(respBody8[0].(map[string]any)["name"].(string))) 304 | this.Assert().Equal("TEST_GOSQLAPI_TOKENS", strings.ToUpper(respBody8[1].(map[string]any)["name"].(string))) 305 | 306 | count-- 307 | fmt.Println("-------------------------------------------------------------", count) 308 | } 309 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U= 4 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM= 5 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4= 6 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4= 7 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= 8 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= 9 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w= 10 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14= 11 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI= 12 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww= 13 | github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs= 14 | github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= 15 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 20 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 21 | github.com/elgs/gosplitargs v0.0.0-20241205072753-cbd889c0f906 h1:Gfn+NcN3eAVFLXd9hN9sTd0vtsYXSGwVUKk6EHFVn3s= 22 | github.com/elgs/gosplitargs v0.0.0-20241205072753-cbd889c0f906/go.mod h1:w1WVg5EhY8yy+53iAGOaUp4JxlmV24K3D21BpFgxqcY= 23 | github.com/elgs/gosqlcrud v0.0.0-20250910094801-55167c4527dc h1:UtauPRQ5tbipqNdyvot5eb8z5XLEtP9TQ2VHpiVNO9U= 24 | github.com/elgs/gosqlcrud v0.0.0-20250910094801-55167c4527dc/go.mod h1:CFbL7OS4w/brXWdIP2C1rYFpS9nEGA11Oc3qb2nTvTY= 25 | github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= 26 | github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 27 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 28 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 29 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= 30 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 31 | github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= 32 | github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= 33 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 34 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 35 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 36 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 37 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 38 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 39 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 40 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 41 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 42 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 43 | github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= 44 | github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= 45 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 46 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 47 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 48 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 49 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 50 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 51 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 52 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 53 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 54 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 55 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 56 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 57 | github.com/microsoft/go-mssqldb v1.9.5 h1:orwya0X/5bsL1o+KasupTkk2eNTNFkTQG0BEe/HxCn0= 58 | github.com/microsoft/go-mssqldb v1.9.5/go.mod h1:VCP2a0KEZZtGLRHd1PsLavLFYy/3xX2yJUPycv3Sr2Q= 59 | github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 60 | github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 61 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 62 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 63 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 64 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 65 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 66 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 67 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 68 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 69 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 70 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 71 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 72 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 73 | github.com/sijms/go-ora/v2 v2.9.0 h1:+iQbUeTeCOFMb5BsOMgUhV8KWyrv9yjKpcK4x7+MFrg= 74 | github.com/sijms/go-ora/v2 v2.9.0/go.mod h1:QgFInVi3ZWyqAiJwzBQA+nbKYKH77tdp1PYoCqhR2dU= 75 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 76 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 77 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 78 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 79 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 80 | golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= 81 | golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= 82 | golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= 83 | golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= 84 | golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= 85 | golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= 86 | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= 87 | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= 88 | golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 89 | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 90 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 92 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 93 | golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 94 | golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 95 | golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= 96 | golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= 97 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 98 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 99 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 100 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 101 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 102 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 103 | modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= 104 | modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 105 | modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= 106 | modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= 107 | modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= 108 | modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 109 | modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 110 | modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 111 | modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= 112 | modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 113 | modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 114 | modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 115 | modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk= 116 | modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= 117 | modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 118 | modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 119 | modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 120 | modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 121 | modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 122 | modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 123 | modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 124 | modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 125 | modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= 126 | modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= 127 | modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 128 | modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 129 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 130 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gosqlapi 2 | 3 | Turns any SQL database into a RESTful API. Currently supports MySQL, MariaDB, 4 | PostgreSQL, Oracle, Microsoft SQL Server and SQLite. 5 | 6 | The intention of `gosqlapi` is not to replace a full-fledged backend server, but 7 | to provide a quick and easy way to expose any SQL databases as a RESTful API 8 | without writing any server code, except for only SQL scripts. 9 | 10 | ## Installation 11 | 12 | Install `gosqlapi` with one of the following commands, depending on the 13 | databases you want to use: 14 | 15 | ### homebrew 16 | 17 | ```sh 18 | $ brew install elgs/taps/gosqlapi 19 | ``` 20 | 21 | or 22 | 23 | ```sh 24 | $ brew tap elgs/taps 25 | $ brew install gosqlapi 26 | ``` 27 | 28 | ### Arch Linux (AUR) 29 | 30 | ```sh 31 | $ yay -S gosqlapi 32 | ``` 33 | 34 | ### go install 35 | 36 | ```sh 37 | # Install gosqlapi for all databases. 38 | $ go install github.com/elgs/gosqlapi@all 39 | 40 | # Install gosqlapi for MySQL and MariaDB - https://github.com/go-sql-driver/mysql 41 | $ go install github.com/elgs/gosqlapi@mysql 42 | 43 | # Install gosqlapi for PostgreSQL - https://github.com/lib/pq 44 | $ go install github.com/elgs/gosqlapi@postgres 45 | 46 | # Install gosqlapi for PostgreSQL - https://github.com/jackc/pgx 47 | $ go install github.com/elgs/gosqlapi@pgx 48 | 49 | # Install gosqlapi for Oracle - https://github.com/sijms/go-ora 50 | $ go install github.com/elgs/gosqlapi@oracle 51 | 52 | # Install gosqlapi for Microsoft SQL Server - https://github.com/microsoft/go-mssqldb 53 | $ go install github.com/elgs/gosqlapi@sqlserver 54 | 55 | # Install gosqlapi for SQLite - https://pkg.go.dev/modernc.org/sqlite 56 | $ go install github.com/elgs/gosqlapi@sqlite 57 | 58 | # Install gosqlapi for all databases from the latest commit. Things may break. 59 | $ go install github.com/elgs/gosqlapi@latest 60 | ``` 61 | 62 | ### Download pre-built binaries 63 | 64 | If you don't have Go installed, you can download pre-built binaries from the 65 | [releases page](https://goweb.az.ht/gosqlapi/build/). 66 | 67 | ## Usage 68 | 69 | If you have a `gosqlapi.json` file in the current directory: 70 | 71 | ```sh 72 | $ gosqlapi 73 | ``` 74 | 75 | or if you don't have `gosqlapi.json` in the current directory: 76 | 77 | ```sh 78 | $ gosqlapi -c /path/to/gosqlapi.json 79 | ``` 80 | 81 | ## Change Log 82 | 83 | ### Version 43 84 | 85 | Previously HTTP PATCH method was used to execute pre-defined SQL queries. Now 86 | HTTP GET method can be used to execute pre-defined SQL queries, if the script 87 | name does not conflict with the table name. The HTTP PATCH method is still 88 | supported. The reason for this change is to allow caching of the pre-defined SQL 89 | queries. So use HTTP GET method to execute pre-defined SQL queries if you want 90 | to cache the results. 91 | 92 | ## Hello World 93 | 94 | ### On the server side 95 | 96 | Prepare `gosqlapi.json` and `init.sql` in the current directory, and run 97 | `gosqlapi`: 98 | 99 | `gosqlapi.json`: 100 | 101 | ```json 102 | { 103 | "web": { 104 | "http_addr": "127.0.0.1:8080", 105 | "cors": true 106 | }, 107 | "databases": { 108 | "test_db": { 109 | "type": "sqlite", 110 | "url": ":memory:" 111 | } 112 | }, 113 | "scripts": { 114 | "init": { 115 | "database": "test_db", 116 | "path": "init.sql", 117 | "public_exec": true 118 | } 119 | }, 120 | "tables": { 121 | "test_table": { 122 | "database": "test_db", 123 | "name": "TEST_TABLE", 124 | "public_read": true, 125 | "public_write": true 126 | } 127 | } 128 | } 129 | ``` 130 | 131 | `init.sql`: 132 | 133 | ```sql 134 | drop TABLE IF EXISTS TEST_TABLE; 135 | create TABLE IF NOT EXISTS TEST_TABLE( 136 | ID INTEGER NOT NULL PRIMARY KEY, 137 | NAME VARCHAR(50) 138 | ); 139 | 140 | insert INTO TEST_TABLE (ID, NAME) VALUES (1, 'Alpha'); 141 | insert INTO TEST_TABLE (ID, NAME) VALUES (2, 'Beta'); 142 | insert INTO TEST_TABLE (ID, NAME) VALUES (3, 'Gamma'); 143 | 144 | -- @label: data 145 | SELECT * FROM TEST_TABLE WHERE ID > ?low? AND ID < ?high?; 146 | ``` 147 | 148 | ### On the client side 149 | 150 | #### Run a pre-defined SQL query 151 | 152 | ```sh 153 | $ curl -X PATCH 'http://localhost:8080/test_db/init/' \ 154 | --header 'Content-Type: application/json' \ 155 | --data-raw '{ 156 | "low": 0, 157 | "high": 3 158 | }' 159 | ``` 160 | 161 | ```json 162 | { 163 | "data": [ 164 | { "id": 1, "name": "Alpha" }, 165 | { "id": 2, "name": "Beta" } 166 | ] 167 | } 168 | ``` 169 | 170 | #### Get a record 171 | 172 | ```sh 173 | $ curl -X GET 'http://localhost:8080/test_db/test_table/1' 174 | ``` 175 | 176 | ```json 177 | { "id": 1, "name": "Alpha" } 178 | ``` 179 | 180 | #### Create a new record 181 | 182 | ```sh 183 | $ curl -X POST 'http://localhost:8080/test_db/test_table' \ 184 | --header 'Content-Type: application/json' \ 185 | --data-raw '{ 186 | "id": 4, 187 | "name": "Delta" 188 | }' 189 | ``` 190 | 191 | ```json 192 | { "last_insert_id": 4, "rows_affected": 1 } 193 | ``` 194 | 195 | #### Update a record 196 | 197 | ```sh 198 | $ curl -X PUT 'http://localhost:8080/test_db/test_table/4' \ 199 | --header 'Content-Type: application/json' \ 200 | --data-raw '{ 201 | "name": "Omega" 202 | }' 203 | ``` 204 | 205 | ```json 206 | { "last_insert_id": 4, "rows_affected": 1 } 207 | ``` 208 | 209 | #### Delete a record 210 | 211 | ```sh 212 | $ curl -X DELETE 'http://localhost:8080/test_db/test_table/4' 213 | ``` 214 | 215 | ```json 216 | { "last_insert_id": 4, "rows_affected": 1 } 217 | ``` 218 | 219 | #### Primary Key 220 | 221 | If the table's primary key is not `ID`, you can specify the primary key for a 222 | table in the `gosqlapi.json` file: 223 | 224 | ```json 225 | { 226 | "tables": { 227 | "test_table": { 228 | "database": "test_db", 229 | "name": "TEST_TABLE", 230 | "primary_key": "UID" 231 | } 232 | } 233 | } 234 | ``` 235 | 236 | #### Search for records 237 | 238 | ```sh 239 | $ curl -X GET 'http://localhost:8080/test_db/test_table?name=Beta' 240 | ``` 241 | 242 | ```json 243 | [{ "id": 2, "name": "Beta" }] 244 | ``` 245 | 246 | #### Search for records with .page_size, .offset and .order_by 247 | 248 | ```sh 249 | $ curl --request GET \ 250 | --url 'http://localhost:8080/test_db/test_table?.page_size=2&.offset=1&.show_total=1' 251 | ``` 252 | 253 | ```json 254 | { 255 | "data": [ 256 | { 257 | "id": 2, 258 | "name": "Beta" 259 | }, 260 | { 261 | "id": 3, 262 | "name": "Gamma" 263 | } 264 | ], 265 | "offset": 1, 266 | "page_size": 2, 267 | "total": 3 268 | } 269 | ``` 270 | 271 | You can use the following parameters: 272 | 273 | - `.page_size`: maximum number of records returned 274 | - `.offset`: offset the number of records returned 275 | - `.order_by`: order the records returned 276 | - `.show_total`: show the total number of records 277 | 278 | You can give a table a default `page_size`, `order_by`, `show_total` and 279 | `exported_columns` by setting `page_size` and `order_by` in `gosqlapi.json`: 280 | 281 | ```json 282 | { 283 | "tables": { 284 | "test_table": { 285 | "database": "test_db", 286 | "name": "TEST_TABLE", 287 | "public_read": true, 288 | "public_write": true, 289 | "page_size": 10, 290 | "order_by": "NAME DESC, ID ASC", 291 | "show_total": true, 292 | "exported_columns": ["ID", "NAME AS USERNAME"] 293 | } 294 | } 295 | } 296 | ``` 297 | 298 | if `exported_columns` is not set or is empty, all columns will be exported. 299 | 300 | ### Passing SQL `NULL` from URL parameters 301 | 302 | You can pass SQL `NULL` from URL parameters by setting `null_value` in 303 | `gosqlapi.json`. For example: 304 | 305 | ```json 306 | { 307 | "null_value": "null" 308 | } 309 | ``` 310 | 311 | Then you can pass `null` from URL parameters: 312 | 313 | ```sh 314 | $ curl -X GET 'http://localhost:8080/test_db/test_table?name=null' 315 | ``` 316 | 317 | ```json 318 | [{ "id": 1, "name": null }] 319 | ``` 320 | 321 | ## Access Control 322 | 323 | When a script has `public_exec` set to true, it can be executed by public users. 324 | When a table has `public_read` set to true, it can be read by public users. When 325 | a table has `public_write` set to true, it can be written by public users. 326 | 327 | When a script or table is set to not be accessible by public users, an auth 328 | token is required to access the script or table. The client should send the auth 329 | token back to the server in the `Authorization` header. The server will verify 330 | the auth token and return an error if the auth token is invalid. 331 | 332 | ### Simple Tokens 333 | 334 | Simple tokens are configured in `gosqlapi.json`: 335 | 336 | ```json 337 | { 338 | "tokens": { 339 | "401d2fe0a18b26b4ce5f16c76cca6d484707f70a3a804d1c2f5e3fa1971d2fc0": [ 340 | { 341 | "target_database": "test_db", 342 | "target_objects": ["test_table"], 343 | "read_private": true, 344 | "write_private": true, 345 | "allowed_origins": ["localhost", "*.example.com"] 346 | }, 347 | { 348 | "target_database": "test_db", 349 | "target_objects": ["init"], 350 | "exec_private": true 351 | } 352 | ] 353 | } 354 | } 355 | ``` 356 | 357 | In the example above, the auth token is configured to allow users to read and 358 | write `test_table` and execute `init` script in `test_db`. 359 | 360 | The `allowed_origins` field is optional. If it is set, the server will only 361 | allow requests from the specified origins. It checks the `Origin` or `Referer` 362 | header to determine the origin of the request. If it is not set, it will reject 363 | all requests. If it is set to `*`, it will allow requests from all origins or 364 | referers. 365 | 366 | You can use `*` to match all databases or all target objects: 367 | 368 | ```json 369 | { 370 | "tokens": { 371 | "401d2fe0a18b26b4ce5f16c76cca6d484707f70a3a804d1c2f5e3fa1971d2fc0": [ 372 | { 373 | "target_database": "*", 374 | "target_objects": ["*"], 375 | "read_private": true, 376 | "write_private": true, 377 | "exec_private": true 378 | } 379 | ] 380 | } 381 | } 382 | ``` 383 | 384 | This token will have super power. 385 | 386 | ### Managed Tokens 387 | 388 | #### Token Table 389 | 390 | Managed tokens are stored in the database. The table and database that will 391 | store managed tokens are configured as `managed_tokens` in `gosqlapi.json`. 392 | 393 | ```json 394 | { 395 | "managed_tokens": { 396 | "database": "test_db", 397 | "table_name": "TOKENS" 398 | } 399 | } 400 | ``` 401 | 402 | The table that stores managed tokens should have the following schema: 403 | 404 | ```sql 405 | CREATE TABLE IF NOT EXISTS `TOKENS` ( 406 | `ID` CHAR(36) NOT NULL, 407 | `USER_ID` CHAR(36) NOT NULL, 408 | `TOKEN` VARCHAR(255) NOT NULL, -- required, auth token 409 | `TARGET_DATABASE` VARCHAR(255) NOT NULL, -- required, target database 410 | `TARGET_OBJECTS` TEXT NOT NULL, -- required, target objects, separated by whitespace 411 | `READ_PRIVATE` INT NOT NULL DEFAULT 0 , -- required, 1: read, 0: no read 412 | `WRITE_PRIVATE` INT NOT NULL DEFAULT 0 , -- required, 1: write, 0: no write 413 | `EXEC_PRIVATE` INT NOT NULL DEFAULT 0 , -- required, 1: exec, 0: no exec 414 | `ALLOWED_ORIGINS` TEXT NOT NULL, -- required, allowed origins or referers, separated by whitespace 415 | CONSTRAINT `PRIMARY` PRIMARY KEY (`ID`) 416 | ); 417 | create INDEX TOKEN_INDEX ON TOKENS (TOKEN); 418 | ``` 419 | 420 | Please feel free to change the ID to a different type, such as `INT`, or add 421 | more columns to the table. The only requirement is that the table should have 422 | the required columns listed above. Also consider adding an index to the `TOKEN` 423 | column. 424 | 425 | When `managed_tokens` is configured in `gosqlapi.json`, the `tokens` in 426 | `gosqlapi.json` will be ignored. 427 | 428 | If you already have a table that stores managed tokens, you can map the fields 429 | in that token table as follows: 430 | 431 | ```json 432 | { 433 | "managed_tokens": { 434 | "database": "test_db", 435 | "table_name": "TOKENS", 436 | "token": "AUTH_TOKEN", 437 | "target_database": "TARGET_DATABASE", 438 | "target_objects": "TARGET_OBJECTS", 439 | "read_private": "READ_PRIVATE", 440 | "write_private": "WRITE_PRIVATE", 441 | "exec_private": "EXEC_PRIVATE", 442 | "allowed_origins": "ALLOWED_ORIGINS" 443 | } 444 | } 445 | ``` 446 | 447 | For example, if your token table has the field `AUTH_TOKEN` instead of `TOKEN`, 448 | you can use the configuration above to map the field `AUTH_TOKEN` to `TOKEN`. 449 | 450 | #### Token Query 451 | 452 | Instead of specifying the `table_name`, you can use a `query` in the config. The 453 | `query` should return the same columns as the token table. 454 | 455 | ```json 456 | { 457 | "managed_tokens": { 458 | "database": "test_db", 459 | "query": "SELECT TARGET_DATABASE AS target_database, TARGET_OBJECTS AS target_objects, READ_PRIVATE AS read_private, WRITE_PRIVATE AS write_private, EXEC_PRIVATE AS exec_private, ALLOWED_ORIGINS AS allowed_origins FROM TOKENS WHERE TOKEN=?token?" 460 | } 461 | } 462 | ``` 463 | 464 | The placeholder will be replaced with the auth token. If the `query` is getting 465 | too long, you can use a separate file to store the query. 466 | 467 | ```json 468 | { 469 | "managed_tokens": { 470 | "database": "test_db", 471 | "query_path": "token_query.sql" 472 | } 473 | } 474 | ``` 475 | 476 | #### Cache Managed Tokens 477 | 478 | In production, you may want to cache the managed tokens in memory. To enable 479 | caching, set `cache_tokens` to `true` in `gosqlapi.json`. This will prevent the 480 | server from querying the database for tokens for every request. 481 | 482 | ```json 483 | { 484 | "cache_tokens": true 485 | } 486 | ``` 487 | 488 | When any token is updated, an update to the cache is necessary. To update the 489 | token cache, send a POST request to `/.clear-tokens` with the following header: 490 | 491 | ``` 492 | Authorization: Bearer 493 | ``` 494 | 495 | ## Pre-defined SQL Queries 496 | 497 | There are a few things to note when defining a pre-defined SQL query in a 498 | script: 499 | 500 | ```sql 501 | drop TABLE IF EXISTS TEST_TABLE; 502 | create TABLE IF NOT EXISTS TEST_TABLE( 503 | ID INTEGER NOT NULL PRIMARY KEY, 504 | NAME TEXT 505 | ); 506 | 507 | insert INTO TEST_TABLE (ID, NAME) VALUES (1, 'Alpha'); 508 | insert INTO TEST_TABLE (ID, NAME) VALUES (2, 'Beta'); 509 | insert INTO TEST_TABLE (ID, NAME) VALUES (3, 'Gamma'); 510 | 511 | -- @label: data 512 | SELECT * FROM TEST_TABLE WHERE ID > ?low? AND ID < ?high?; 513 | ``` 514 | 515 | 1. You can define multiple SQL statements in a single script. The statements 516 | will be executed in the order they appear in the script. The script will be 517 | executed in a transaction. If any statement fails, the transaction will be 518 | rolled back, and if all statements succeed, the transaction will be 519 | committed. Statements in the script are separated by `;`. 520 | 2. The results of the statements that start with an uppercase letter will be 521 | returned to the client. The results of the statements that start with a 522 | lowercase letter will not be returned to the client. 523 | 3. You can label a statement with `-- @label: label_name`. The `label_name` will 524 | be the key of the result in the returned JSON object. 525 | 4. You can use `?param_name?` to define a parameter. The `param_name` will be 526 | the key of the parameter in the JSON object sent to the server. 527 | 528 | ### Inline scripts 529 | 530 | You have the option to define a script inline in the `gosqlapi.json` file. This 531 | is useful when you want to define a script that is short or you don't want to 532 | create a separate file for the script. The script can be defined in the 533 | `gosqlapi.json` file as follows: 534 | 535 | ```json 536 | { 537 | "scripts": { 538 | "init": { 539 | "database": "test_db", 540 | "sql": "drop TABLE IF EXISTS TEST_TABLE; create TABLE IF NOT EXISTS TEST_TABLE( ID INTEGER NOT NULL PRIMARY KEY, NAME TEXT ); insert INTO TEST_TABLE (ID, NAME) VALUES (1, 'Alpha'); insert INTO TEST_TABLE (ID, NAME) VALUES (2, 'Beta'); insert INTO TEST_TABLE (ID, NAME) VALUES (3, 'Gamma'); -- @label: data \n SELECT * FROM TEST_TABLE WHERE ID > ?low? AND ID < ?high?;" 541 | } 542 | } 543 | } 544 | ``` 545 | 546 | When both `sql` and `path` are defined, `path` will be used, and `sql` will be 547 | ignored. 548 | 549 | ### Edit scripts in dev mode 550 | 551 | When the server is running in dev mode, the server will not cache the scripts 552 | and will reload the scripts every time a request is made. This is useful when 553 | you are editing the scripts so that you don't have to restart the server every 554 | time you make a change. To run the server in dev mode, set the `env` environment 555 | variable to `dev` when starting the server: 556 | 557 | ```sh 558 | $ env=dev gosqlapi 559 | ``` 560 | 561 | `dev` mode is only effective for scripts defined in `gosqlapi.json` by `path`. 562 | For scripts defined in `gosqlapi.json` by `sql`, `dev` mode will not be 563 | effective. 564 | 565 | Do not use dev mode in production, as it will read the scripts from the disk 566 | every time a request is made. 567 | 568 | ## Request Metadata in Pre-defined SQL Queries 569 | 570 | You can access the request metadata in pre-defined SQL queries. The request 571 | metadata includes the request method, the request path, the request query 572 | string, and the request headers. The request metadata can be accessed in the 573 | pre-defined SQL queries as follows: 574 | 575 | ```sql 576 | SELECT 577 | !remote_addr! as "REMOTE_ADDRESS", 578 | !host! as "HOST", 579 | !method! as "METHOD", 580 | !path! as "PATH", 581 | !query! as "QUERY", 582 | !user_agent! as "USER_AGENT", 583 | !referer! as "REFERER", 584 | !accept! as "ACCEPT", 585 | !AUThorization! as "AUTHORIZATION"; 586 | ``` 587 | 588 | The request metadata parameters are case-insensitive. The request metadata 589 | parameters are surrounded by `!` characters. 590 | 591 | ## Database Configuration 592 | 593 | ### SQLite 594 | 595 | ```json 596 | { 597 | "databases": { 598 | "test_db": { 599 | "type": "sqlite", 600 | "url": "./test_db.sqlite3" 601 | } 602 | } 603 | } 604 | ``` 605 | 606 | https://pkg.go.dev/modernc.org/sqlite 607 | 608 | ### MySQL and MariaDB 609 | 610 | ```json 611 | { 612 | "databases": { 613 | "test_db": { 614 | "type": "mysql", 615 | "url": "user:pass@tcp(localhost:3306)/test_db" 616 | } 617 | } 618 | } 619 | ``` 620 | 621 | https://github.com/go-sql-driver/mysql 622 | 623 | ### PostgreSQL (pq or pgx) 624 | 625 | ```json 626 | { 627 | "databases": { 628 | "test_db": { 629 | "type": "pq", 630 | "url": "postgres://user:pass@localhost:5432/test_db?sslmode=disable" 631 | } 632 | } 633 | } 634 | ``` 635 | 636 | https://github.com/lib/pq 637 | 638 | ```json 639 | { 640 | "databases": { 641 | "test_db": { 642 | "type": "pgx", 643 | "url": "postgres://user:pass@localhost:5432/test_db" 644 | } 645 | } 646 | } 647 | ``` 648 | 649 | https://github.com/jackc/pgx 650 | 651 | ### Microsoft SQL Server 652 | 653 | ```json 654 | { 655 | "databases": { 656 | "test_db": { 657 | "type": "sqlserver", 658 | "url": "sqlserver://user:pass@localhost:1433?database=test_db" 659 | } 660 | } 661 | } 662 | ``` 663 | 664 | https://github.com/microsoft/go-mssqldb 665 | 666 | ### Oracle 667 | 668 | ```json 669 | { 670 | "databases": { 671 | "test_db": { 672 | "type": "oracle", 673 | "url": "oracle://user:pass@localhost:1521/test_db" 674 | } 675 | } 676 | } 677 | ``` 678 | 679 | ### Oracle Cloud TLS 680 | 681 | ```json 682 | { 683 | "databases": { 684 | "test_db": { 685 | "type": "oracle", 686 | "url": "oracle://user:pass@:0/?SSL VERIFY=FALSE&connStr=(description=(retry_count=20)(retry_delay=3)(address=(protocol=tcps)(port=1521)(host=host))(connect_data=(service_name=service_name))(security=(ssl_server_dn_match=yes)))" 687 | } 688 | } 689 | } 690 | ``` 691 | 692 | https://github.com/sijms/go-ora 693 | 694 | ### Store passwords in environment variables 695 | 696 | If you don't want to expose the database password in the `gosqlapi.json` file, 697 | you can store the password in an environment variable and reference the 698 | environment variable in the `gosqlapi.json` file with `env:`. For example: 699 | 700 | ```json 701 | { 702 | "databases": { 703 | "test_db": { 704 | "type": "env:db_type", 705 | "url": "env:db_url" 706 | } 707 | } 708 | } 709 | ``` 710 | 711 | The environment variables `db_type` and `db_url` will be used to configure the 712 | database. 713 | 714 | ```sh 715 | $ db_type=sqlite db_url=./test_db.sqlite3 gosqlapi 716 | ``` 717 | 718 | ## HTTPS 719 | 720 | Here is an example of how to configure HTTPS: 721 | 722 | ```json 723 | { 724 | "web": { 725 | "http_addr": "127.0.0.1:8080", 726 | "https_addr": "127.0.0.1:8443", 727 | "cert_file": "/path/to/cert.pem", 728 | "key_file": "/path/to/key.pem", 729 | "cors": true 730 | } 731 | } 732 | ``` 733 | 734 | ## Custom HTTP Headers 735 | 736 | You can add custom HTTP headers to the response. For example, you can add the 737 | following to the `gosqlapi.json` file: 738 | 739 | ```json 740 | { 741 | "web": { 742 | "http_addr": "127.0.0.1:8080", 743 | "cors": false, 744 | "http_headers": { 745 | "Access-Control-Allow-Origin": "https://example.com" 746 | } 747 | } 748 | } 749 | ``` 750 | 751 | ## Auto start with systemd 752 | 753 | Create service unit file `/etc/systemd/system/gosqlapi.service` with the 754 | following content: 755 | 756 | ``` 757 | [Unit] 758 | After=network.target 759 | 760 | [Service] 761 | WorkingDirectory=/home/user/gosqlapi/ 762 | ExecStart=/home/user/go/bin/gosqlapi -c /home/user/gosqlapi/gosqlapi.json 763 | 764 | [Install] 765 | WantedBy=default.target 766 | ``` 767 | 768 | Enable the service: 769 | 770 | ``` 771 | $ sudo systemctl enable gosqlapi 772 | ``` 773 | 774 | Remove the service: 775 | 776 | ``` 777 | $ sudo systemctl disable gosqlapi 778 | ``` 779 | 780 | Start the service 781 | 782 | ``` 783 | $ sudo systemctl start gosqlapi 784 | ``` 785 | 786 | Stop the service 787 | 788 | ``` 789 | $ sudo systemctl stop gosqlapi 790 | ``` 791 | 792 | Check service status 793 | 794 | ```sh 795 | $ sudo systemctl status gosqlapi 796 | ``` 797 | 798 | ## Why is mattn/go-sqlite3 removed 799 | 800 | Because I cannot get cross compile to work for Windows ARM64. If you have a 801 | clue, please let me know. Thanks. If you are a macOS or Linux user, you can 802 | still use mattn/go-sqlite3 by: 803 | 804 | ```sh 805 | $ git clone https://github.com/elgs/gosqlapi 806 | $ cd gosqlapi 807 | $ git checkout sqlite3 808 | $ go build 809 | ``` 810 | 811 | The sqlite driver from `modernc.org/sqlite` is used by default. 812 | 813 | ## License 814 | 815 | MIT License 816 | 817 | Copyright (c) 2024 Qian Chen 818 | 819 | Permission is hereby granted, free of charge, to any person obtaining a copy of 820 | this software and associated documentation files (the "Software"), to deal in 821 | the Software without restriction, including without limitation the rights to 822 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 823 | the Software, and to permit persons to whom the Software is furnished to do so, 824 | subject to the following conditions: 825 | 826 | The above copyright notice and this permission notice shall be included in all 827 | copies or substantial portions of the Software. 828 | 829 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 830 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 831 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 832 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 833 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 834 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 835 | -------------------------------------------------------------------------------- /gosqlapi.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "regexp" 14 | "strconv" 15 | "strings" 16 | 17 | "github.com/elgs/gosplitargs" 18 | "github.com/elgs/gosqlcrud" 19 | ) 20 | 21 | func NewApp(confBytes []byte) (*App, error) { 22 | var app *App 23 | err := json.Unmarshal(confBytes, &app) 24 | if err != nil { 25 | return nil, err 26 | } 27 | err = app.buildTokenQuery() 28 | if err != nil { 29 | return nil, err 30 | } 31 | return app, nil 32 | } 33 | 34 | func (this *App) run() { 35 | mux := http.NewServeMux() 36 | mux.HandleFunc("/{db}/{obj}", this.defaultHandler) 37 | mux.HandleFunc("/{db}/{obj}/", this.defaultHandler) 38 | mux.HandleFunc("/{db}/{obj}/{key}", this.defaultHandler) 39 | mux.HandleFunc("/{db}/{obj}/{key}/", this.defaultHandler) 40 | 41 | if this.Web.HttpAddr != "" { 42 | this.Web.httpServer = &http.Server{ 43 | Addr: this.Web.HttpAddr, 44 | Handler: mux, 45 | } 46 | go func() { 47 | err := this.Web.httpServer.ListenAndServe() 48 | if err != nil { 49 | log.Fatalf("Failed to listen on http://%s/, %v\n", this.Web.HttpAddr, err) 50 | } 51 | }() 52 | log.Printf("Listening on http://%s/\n", this.Web.HttpAddr) 53 | } 54 | 55 | if this.Web.HttpsAddr != "" { 56 | this.Web.httpsServer = &http.Server{ 57 | Addr: this.Web.HttpsAddr, 58 | Handler: mux, 59 | } 60 | go func() { 61 | err := this.Web.httpsServer.ListenAndServeTLS(this.Web.CertFile, this.Web.KeyFile) 62 | if err != nil { 63 | log.Fatalf("Failed to listen on https://%s/, %v\n", this.Web.HttpsAddr, err) 64 | } 65 | }() 66 | log.Printf("Listening on https://%s/\n", this.Web.HttpsAddr) 67 | } 68 | } 69 | 70 | func (this *App) shutdown() { 71 | if this.Web.httpServer != nil { 72 | this.Web.httpServer.Shutdown(context.Background()) 73 | } 74 | if this.Web.httpsServer != nil { 75 | this.Web.httpsServer.Shutdown(context.Background()) 76 | } 77 | } 78 | 79 | func (this *App) GetDatabase(databaseId string) (*Database, error) { 80 | if database, ok := this.Databases[databaseId]; ok { 81 | if database.dbType == gosqlcrud.Unknown { 82 | _, err := database.GetConn() 83 | if err != nil { 84 | return nil, err 85 | } 86 | } 87 | return database, nil 88 | } 89 | return nil, fmt.Errorf("database %s not found", databaseId) 90 | } 91 | 92 | func (this *Database) GetConn() (*sql.DB, error) { 93 | if this.conn != nil { 94 | return this.conn, nil 95 | } 96 | var err error 97 | if strings.HasPrefix(this.Type, "env:") { 98 | env := strings.TrimPrefix(this.Type, "env:") 99 | this.Type = os.Getenv(env) 100 | } 101 | if strings.HasPrefix(this.Url, "env:") { 102 | env := strings.TrimPrefix(this.Url, "env:") 103 | this.Url = os.Getenv(env) 104 | } 105 | this.conn, err = sql.Open(this.Type, this.Url) 106 | if err != nil { 107 | return nil, err 108 | } 109 | this.dbType = gosqlcrud.GetDbType(this.conn) 110 | return this.conn, err 111 | } 112 | 113 | func (this *Database) GetLimitClause(limit int, offset int) string { 114 | switch this.dbType { 115 | case gosqlcrud.PostgreSQL, gosqlcrud.MySQL, gosqlcrud.SQLite: 116 | return fmt.Sprintf("LIMIT %d OFFSET %d", limit, offset) 117 | case gosqlcrud.SQLServer, gosqlcrud.Oracle: 118 | return fmt.Sprintf("OFFSET %d ROWS FETCH NEXT %d ROWS ONLY", offset, limit) 119 | } 120 | return "" 121 | } 122 | 123 | func (this *Database) BuildStatements(script *Script) error { 124 | script.Statements = nil 125 | script.built = false 126 | statements, err := gosplitargs.SplitSQL(script.SQL, ";", true) 127 | if err != nil { 128 | return err 129 | } 130 | 131 | for _, statementString := range statements { 132 | statementString = strings.TrimSpace(statementString) 133 | if statementString == "" { 134 | continue 135 | } 136 | label, statementSQL := SplitSqlLabel(statementString) 137 | if statementSQL == "" { 138 | continue 139 | } 140 | params := this.ExtractSQLParameters(&statementSQL) 141 | statement := &Statement{ 142 | Label: label, 143 | SQL: statementSQL, 144 | Params: params, 145 | Script: script, 146 | Query: IsQuery(statementSQL), 147 | Export: ShouldExport(statementSQL), 148 | } 149 | script.Statements = append(script.Statements, statement) 150 | } 151 | script.built = true 152 | return nil 153 | } 154 | 155 | func (this *Database) ExtractSQLParameters(s *string) []string { 156 | params := []string{} 157 | r := regexp.MustCompile(`\?(.+?)\?`) 158 | m := r.FindAllStringSubmatch(*s, -1) 159 | for _, v := range m { 160 | if len(v) >= 2 { 161 | params = append(params, v[1]) 162 | } 163 | } 164 | indexes := r.FindAllStringSubmatchIndex(*s, -1) 165 | temp := []string{} 166 | lastIndex := 0 167 | for index, match := range indexes { 168 | temp = append(temp, (*s)[lastIndex:match[0]]) 169 | temp = append(temp, gosqlcrud.GetPlaceHolder(index, this.dbType)) 170 | lastIndex = match[1] 171 | } 172 | temp = append(temp, (*s)[lastIndex:]) 173 | *s = strings.Join(temp, "") 174 | return params 175 | } 176 | 177 | func (this *App) buildTokenQuery() error { 178 | if this.ManagedTokens == nil { 179 | return nil 180 | } 181 | if this.ManagedTokens.QueryPath != "" { 182 | tokenQuery, err := os.ReadFile(this.ManagedTokens.QueryPath) 183 | if err != nil { 184 | return err 185 | } 186 | this.ManagedTokens.Query = string(tokenQuery) 187 | this.ManagedTokens.QueryPath = "" 188 | } 189 | 190 | if this.ManagedTokens.Query == "" { 191 | 192 | if this.ManagedTokens.TableName == "" { 193 | this.ManagedTokens.TableName = "TOKENS" 194 | } 195 | if this.ManagedTokens.Token == "" { 196 | this.ManagedTokens.Token = "TOKEN" 197 | } 198 | if this.ManagedTokens.TargetDatabase == "" { 199 | this.ManagedTokens.TargetDatabase = "TARGET_DATABASE" 200 | } 201 | if this.ManagedTokens.TargetObjects == "" { 202 | this.ManagedTokens.TargetObjects = "TARGET_OBJECTS" 203 | } 204 | if this.ManagedTokens.ReadPrivate == "" { 205 | this.ManagedTokens.ReadPrivate = "READ_PRIVATE" 206 | } 207 | if this.ManagedTokens.WritePrivate == "" { 208 | this.ManagedTokens.WritePrivate = "WRITE_PRIVATE" 209 | } 210 | if this.ManagedTokens.ExecPrivate == "" { 211 | this.ManagedTokens.ExecPrivate = "EXEC_PRIVATE" 212 | } 213 | if this.ManagedTokens.AllowedOrigins == "" { 214 | this.ManagedTokens.AllowedOrigins = "ALLOWED_ORIGINS" 215 | } 216 | 217 | this.ManagedTokens.Query = fmt.Sprintf(`SELECT 218 | %s AS "target_database", 219 | %s AS "target_objects", 220 | %s AS "read_private", 221 | %s AS "write_private", 222 | %s AS "exec_private", 223 | %s AS "allowed_origins" 224 | FROM %s WHERE %s=?token?`, 225 | this.ManagedTokens.TargetDatabase, 226 | this.ManagedTokens.TargetObjects, 227 | this.ManagedTokens.ReadPrivate, 228 | this.ManagedTokens.WritePrivate, 229 | this.ManagedTokens.ExecPrivate, 230 | this.ManagedTokens.TableName, 231 | this.ManagedTokens.AllowedOrigins, 232 | this.ManagedTokens.Token) 233 | } 234 | tokenDb, err := this.GetDatabase(this.ManagedTokens.Database) 235 | if err != nil { 236 | return err 237 | } 238 | placeholder := gosqlcrud.GetPlaceHolder(0, tokenDb.dbType) 239 | this.ManagedTokens.Query = strings.ReplaceAll(this.ManagedTokens.Query, "?token?", placeholder) 240 | qs, err := gosplitargs.SplitSQL(this.ManagedTokens.Query, ";", true) 241 | if err != nil { 242 | return err 243 | } 244 | if len(qs) == 0 { 245 | return fmt.Errorf("no query found") 246 | } 247 | this.ManagedTokens.Query = qs[0] 248 | gosqlcrud.SqlSafe(&this.ManagedTokens.Query) 249 | return nil 250 | } 251 | 252 | func (this *App) defaultHandler(w http.ResponseWriter, r *http.Request) { 253 | if this.Web.Cors { 254 | w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) 255 | w.Header().Set("Access-Control-Allow-Credentials", "true") 256 | w.Header().Set("Access-Control-Allow-Methods", r.Header.Get("Access-Control-Request-Method")) 257 | w.Header().Set("Access-Control-Allow-Headers", r.Header.Get("Access-Control-Request-Headers")) 258 | } 259 | 260 | if r.Method == "OPTIONS" { 261 | w.Header().Set("Allow", "GET,POST,PUT,PATCH,DELETE,OPTIONS") 262 | return 263 | } 264 | 265 | if this.Web.HttpHeaders != nil { 266 | for k, v := range this.Web.HttpHeaders { 267 | w.Header().Set(k, v) 268 | } 269 | } 270 | 271 | w.Header().Set("gosqlapi-server-version", version) 272 | 273 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 274 | 275 | authorization := r.Header.Get("authorization") 276 | if strings.HasPrefix(strings.ToLower(authorization), "bearer ") { 277 | authorization = strings.TrimSpace(authorization[7:]) 278 | } 279 | 280 | databaseId := r.PathValue("db") 281 | 282 | if this.CacheTokens && databaseId == ".clear-tokens" && authorization != "" { 283 | delete(this.tokenCache, authorization) 284 | w.WriteHeader(http.StatusOK) 285 | fmt.Fprintf(w, `{"success":"token cleared"}`) 286 | return 287 | } 288 | 289 | database, err := this.GetDatabase(databaseId) 290 | if err != nil { 291 | w.WriteHeader(http.StatusForbidden) 292 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 293 | return 294 | } 295 | objectId := r.PathValue("obj") 296 | 297 | methodUpper := strings.ToUpper(r.Method) 298 | 299 | origin := r.Header.Get("Origin") 300 | referer := r.Header.Get("Referer") 301 | 302 | if strings.HasPrefix(origin, "http://") || strings.HasPrefix(origin, "https://") { 303 | originUrl, err := url.Parse(origin) 304 | if err != nil { 305 | w.WriteHeader(http.StatusForbidden) 306 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 307 | return 308 | } 309 | origin = originUrl.Hostname() 310 | } 311 | if strings.HasPrefix(referer, "http://") || strings.HasPrefix(referer, "https://") { 312 | refererUrl, err := url.Parse(referer) 313 | if err != nil { 314 | w.WriteHeader(http.StatusForbidden) 315 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 316 | return 317 | } 318 | referer = refererUrl.Hostname() 319 | } 320 | authorized, err := this.authorize(methodUpper, authorization, databaseId, objectId, origin, referer) 321 | if !authorized { 322 | w.WriteHeader(http.StatusUnauthorized) 323 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 324 | return 325 | } 326 | 327 | body, err := io.ReadAll(r.Body) 328 | if err != nil { 329 | w.WriteHeader(http.StatusForbidden) 330 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 331 | return 332 | } 333 | defer r.Body.Close() 334 | var bodyData map[string]any 335 | json.Unmarshal(body, &bodyData) 336 | 337 | paramValues, err := url.ParseQuery(r.URL.RawQuery) 338 | if err != nil { 339 | w.WriteHeader(http.StatusForbidden) 340 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 341 | return 342 | } 343 | params := valuesToMap(false, this.NullValue, paramValues) 344 | for k, v := range bodyData { 345 | params[k] = v 346 | } 347 | 348 | var result any 349 | 350 | if methodUpper == http.MethodPatch || (methodUpper == http.MethodGet && this.Tables[objectId] == nil) { 351 | script := this.Scripts[objectId] 352 | if script == nil { 353 | w.WriteHeader(http.StatusForbidden) 354 | fmt.Fprintf(w, `{"error":"script %s not found"}`, objectId) 355 | return 356 | } 357 | script.SQL = strings.TrimSpace(script.SQL) 358 | script.Path = strings.TrimSpace(script.Path) 359 | 360 | if os.Getenv("env") == "dev" { 361 | script.built = false 362 | } 363 | 364 | if !script.built { 365 | if script.SQL == "" && script.Path == "" { 366 | w.WriteHeader(http.StatusForbidden) 367 | fmt.Fprintf(w, `{"error":"script %s is empty"}`, objectId) 368 | return 369 | } 370 | 371 | if script.Path != "" { 372 | f, err := os.ReadFile(script.Path) 373 | if err != nil { 374 | w.WriteHeader(http.StatusForbidden) 375 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 376 | return 377 | } 378 | script.SQL = string(f) 379 | } 380 | 381 | err = database.BuildStatements(script) 382 | if err != nil { 383 | w.WriteHeader(http.StatusForbidden) 384 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 385 | return 386 | } 387 | this.Scripts[objectId] = script 388 | } 389 | 390 | result, err = runExec(database, script, params, r) 391 | if err != nil { 392 | w.WriteHeader(http.StatusForbidden) 393 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 394 | return 395 | } 396 | } else { 397 | dataId := r.PathValue("key") 398 | table := this.Tables[objectId] 399 | if table == nil { 400 | w.WriteHeader(http.StatusForbidden) 401 | fmt.Fprintf(w, `{"error":"table %s not found"}`, objectId) 402 | return 403 | } 404 | result, err = runTable(methodUpper, database, table, dataId, params) 405 | if err != nil { 406 | w.WriteHeader(http.StatusForbidden) 407 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 408 | return 409 | } 410 | if result == nil { 411 | w.WriteHeader(http.StatusNotFound) 412 | fmt.Fprintf(w, `{"error":"record %s not found for database %s and object %s"}`, dataId, databaseId, objectId) 413 | return 414 | } else if f, ok := result.(map[string]int64); ok && f["rows_affected"] == 0 { 415 | w.WriteHeader(http.StatusNotFound) 416 | fmt.Fprintf(w, `{"error":"record %s not found for database %s and object %s"}`, dataId, databaseId, objectId) 417 | return 418 | } 419 | } 420 | 421 | jsonData, err := json.Marshal(result) 422 | if err != nil { 423 | w.WriteHeader(http.StatusForbidden) 424 | fmt.Fprintf(w, `{"error":"%s"}`, err.Error()) 425 | return 426 | } 427 | jsonString := string(jsonData) 428 | fmt.Fprintln(w, jsonString) 429 | } 430 | 431 | func (this *App) authorize(methodUpper string, authorization string, databaseId string, objectId string, origin string, referer string) (bool, error) { 432 | 433 | // if object is not found, return false 434 | // if object is found, check if it is public 435 | // if object is not public, return true regardless of token 436 | // if database is not specified in object, the object is shared across all databases 437 | if methodUpper == http.MethodPatch || (methodUpper == http.MethodGet && this.Tables[objectId] == nil) { 438 | script := this.Scripts[objectId] 439 | if script == nil || (script.Database != "" && script.Database != databaseId) { 440 | return false, fmt.Errorf("script %s not found", objectId) 441 | } 442 | if script.PublicExec { 443 | return true, nil 444 | } 445 | } else { 446 | table := this.Tables[objectId] 447 | if table == nil || (table.Database != "" && table.Database != databaseId) { 448 | return false, fmt.Errorf("table %s not found", objectId) 449 | } 450 | if table.PublicRead && methodUpper == http.MethodGet { 451 | return true, nil 452 | } 453 | if table.PublicWrite && (methodUpper == http.MethodPost || methodUpper == http.MethodPut || methodUpper == http.MethodDelete) { 454 | return true, nil 455 | } 456 | } 457 | 458 | // managed tokens 459 | if this.ManagedTokens != nil { 460 | if x, ok := this.tokenCache[authorization]; ok { 461 | return this.hasAccess(methodUpper, x, databaseId, objectId, origin, referer) 462 | } 463 | managedTokensDatabase, err := this.GetDatabase(this.ManagedTokens.Database) 464 | if err != nil { 465 | return false, err 466 | } 467 | tokenDB, err := managedTokensDatabase.GetConn() 468 | if err != nil { 469 | return false, err 470 | } 471 | 472 | accesses := []Access{} 473 | err = gosqlcrud.QueryToStructs(tokenDB, &accesses, this.ManagedTokens.Query, authorization) 474 | if err != nil { 475 | return false, err 476 | } 477 | for index := range accesses { 478 | access := &accesses[index] 479 | access.TargetObjectArray = strings.Fields(access.TargetObjects) 480 | access.AllowedOriginArray = strings.Fields(access.AllowedOrigins) 481 | } 482 | x := ArrayOfStructsToArrayOfPointersOfStructs(accesses) 483 | if this.tokenCache == nil { 484 | this.tokenCache = make(map[string][]*Access) 485 | } 486 | this.tokenCache[authorization] = x 487 | return this.hasAccess(methodUpper, x, databaseId, objectId, origin, referer) 488 | } 489 | 490 | // object is not public, check token 491 | // if token doesn't have any access, return false 492 | accesses := this.Tokens[authorization] 493 | if len(accesses) == 0 { 494 | return false, fmt.Errorf("access denied") 495 | } else { 496 | // when token has access, check if any access is allowed for database and object 497 | return this.hasAccess(methodUpper, accesses, databaseId, objectId, origin, referer) 498 | } 499 | } 500 | 501 | func hostMatch(host string, hostPattern string) bool { 502 | if host == hostPattern { 503 | return true 504 | } 505 | if strings.HasPrefix(hostPattern, "*") { 506 | return strings.HasSuffix(host, hostPattern[1:]) 507 | } 508 | return false 509 | } 510 | 511 | func originOk(origin string, referer string, allowedOrigins []string) bool { 512 | for _, allowedOrigin := range allowedOrigins { 513 | if hostMatch(origin, allowedOrigin) || hostMatch(referer, allowedOrigin) { 514 | return true 515 | } 516 | } 517 | return false 518 | } 519 | 520 | func (this *App) hasAccess(methodUpper string, accesses []*Access, databaseId string, objectId string, origin string, referer string) (bool, error) { 521 | for _, access := range accesses { 522 | if (access.TargetDatabase == databaseId || access.TargetDatabase == "*") && 523 | (Contains(access.TargetObjectArray, objectId) || Contains(access.TargetObjectArray, "*")) && 524 | originOk(origin, referer, access.AllowedOriginArray) { 525 | switch methodUpper { 526 | case http.MethodPatch: 527 | if access.ExecPrivate { 528 | return true, nil 529 | } 530 | case http.MethodGet: 531 | if this.Tables[objectId] == nil { 532 | if access.ExecPrivate { 533 | return true, nil 534 | } 535 | } else { 536 | if access.ReadPrivate { 537 | return true, nil 538 | } 539 | } 540 | case http.MethodPost, http.MethodPut, http.MethodDelete: 541 | if access.WritePrivate { 542 | return true, nil 543 | } 544 | } 545 | } 546 | } 547 | return false, fmt.Errorf("access token not allowed for database %s and object %s", databaseId, objectId) 548 | } 549 | 550 | func runTable(method string, database *Database, table *Table, dataId string, params map[string]any) (any, error) { 551 | gosqlcrud.SqlSafe(&table.Name) 552 | gosqlcrud.SqlSafe(&dataId) 553 | if table.PrimaryKey == "" { 554 | table.PrimaryKey = "ID" 555 | } 556 | db, err := database.GetConn() 557 | if err != nil { 558 | return nil, err 559 | } 560 | switch method { 561 | case http.MethodGet: 562 | if dataId == "" { 563 | pageSize := 0 564 | switch _pageSize := params[".page_size"].(type) { 565 | case string: 566 | pageSize, err = strconv.Atoi(_pageSize) 567 | if err != nil { 568 | return nil, err 569 | } 570 | case int: 571 | pageSize = _pageSize 572 | case int64: 573 | pageSize = int(_pageSize) 574 | } 575 | if pageSize == 0 { 576 | pageSize = table.PageSize 577 | } 578 | if pageSize == 0 { 579 | pageSize = 100 580 | } 581 | 582 | offset := 0 583 | switch _offset := params[".offset"].(type) { 584 | case string: 585 | offset, err = strconv.Atoi(_offset) 586 | if err != nil { 587 | return nil, err 588 | } 589 | case int: 590 | offset = _offset 591 | case int64: 592 | offset = int(_offset) 593 | } 594 | 595 | limitClause := database.GetLimitClause(pageSize, offset) 596 | 597 | orderBy := params[".order_by"] 598 | if orderBy == nil { 599 | orderBy = table.OrderBy 600 | } 601 | orderbyClause := "" 602 | if orderBy != nil && orderBy != "" { 603 | orderbyClause = fmt.Sprintf("ORDER BY %s", orderBy) 604 | } 605 | 606 | if database.Type == "sqlserver" { 607 | if orderbyClause == "" && limitClause != "" { 608 | orderbyClause = "ORDER BY (SELECT NULL)" 609 | } 610 | } 611 | 612 | gosqlcrud.SqlSafe(&limitClause) 613 | gosqlcrud.SqlSafe(&orderbyClause) 614 | 615 | where, values, err := gosqlcrud.MapForSqlWhere(params, 0, database.dbType) 616 | if err != nil { 617 | return nil, err 618 | } 619 | 620 | columns := "*" 621 | if len(table.ExportedColumns) > 0 { 622 | columns = strings.Join(table.ExportedColumns, ", ") 623 | } 624 | gosqlcrud.SqlSafe(&columns) 625 | 626 | q := fmt.Sprintf(`SELECT %s FROM %s WHERE 1=1 %s %s %s`, columns, table.Name, where, orderbyClause, limitClause) 627 | data, err := gosqlcrud.QueryToMaps(db, q, values...) 628 | if err != nil { 629 | return nil, err 630 | } 631 | 632 | showTotal := false 633 | switch _showTotal := params[".show_total"].(type) { 634 | case string: 635 | showTotal = _showTotal == "true" || _showTotal == "1" || _showTotal == "yes" 636 | case bool: 637 | showTotal = _showTotal 638 | case int: 639 | showTotal = _showTotal == 1 640 | case int64: 641 | showTotal = _showTotal == 1 642 | case nil: 643 | showTotal = table.ShowTotal 644 | } 645 | 646 | if showTotal { 647 | qt := fmt.Sprintf(`SELECT COUNT(*) AS TOTAL FROM %s WHERE 1=1 %s`, table.Name, where) 648 | _total, err := gosqlcrud.QueryToMaps(db, qt, values...) 649 | if err != nil { 650 | return nil, err 651 | } 652 | 653 | total := 0 654 | switch v := _total[0]["total"].(type) { 655 | case string: 656 | total, err = strconv.Atoi(v) 657 | if err != nil { 658 | return nil, err 659 | } 660 | case int: 661 | total = v 662 | case int64: 663 | total = int(v) 664 | case uint64: 665 | total = int(v) 666 | case float64: 667 | total = int(v) 668 | } 669 | 670 | return map[string]any{ 671 | "total": total, 672 | "page_size": pageSize, 673 | "offset": offset, 674 | "data": data, 675 | }, nil 676 | } else { 677 | return data, nil 678 | } 679 | } else { 680 | placeholder := gosqlcrud.GetPlaceHolder(0, database.dbType) 681 | r, err := gosqlcrud.QueryToMaps(db, fmt.Sprintf(`SELECT * FROM %s WHERE %s=%s`, table.Name, table.PrimaryKey, placeholder), dataId) 682 | if err != nil { 683 | return nil, err 684 | } 685 | if len(r) == 0 { 686 | return nil, nil 687 | } else { 688 | return r[0], nil 689 | } 690 | } 691 | case http.MethodPost: 692 | qms, keys, values, err := gosqlcrud.MapForSqlInsert(params, database.dbType) 693 | if err != nil { 694 | return nil, err 695 | } 696 | return gosqlcrud.Exec(db, fmt.Sprintf(`INSERT INTO %s (%s) VALUES (%s)`, table.Name, keys, qms), values...) 697 | case http.MethodPut: 698 | setClause, values, err := gosqlcrud.MapForSqlUpdate(params, database.dbType) 699 | if err != nil { 700 | return nil, err 701 | } 702 | placeholder := gosqlcrud.GetPlaceHolder(len(params), database.dbType) 703 | values = append(values, dataId) 704 | return gosqlcrud.Exec(db, fmt.Sprintf(`UPDATE %s SET %s WHERE %s=%s`, table.Name, setClause, table.PrimaryKey, placeholder), values...) 705 | case http.MethodDelete: 706 | placeholder := gosqlcrud.GetPlaceHolder(0, database.dbType) 707 | return gosqlcrud.Exec(db, fmt.Sprintf(`DELETE FROM %s WHERE %s=%s`, table.Name, table.PrimaryKey, placeholder), dataId) 708 | } 709 | return nil, fmt.Errorf("Method %s not supported.", method) 710 | } 711 | 712 | func runExec(database *Database, script *Script, params map[string]any, r *http.Request) (any, error) { 713 | db, err := database.GetConn() 714 | if err != nil { 715 | return nil, err 716 | } 717 | exportedResults := map[string]any{} 718 | 719 | tx, err := db.Begin() 720 | if err != nil { 721 | return nil, err 722 | } 723 | 724 | for _, statement := range script.Statements { 725 | if statement.SQL == "" { 726 | continue 727 | } 728 | statementSQL := statement.SQL 729 | 730 | ReplaceRequestParameters(&statementSQL, r) 731 | 732 | var result any 733 | sqlParams := []any{} 734 | for _, param := range statement.Params { 735 | if val, ok := params[param]; ok { 736 | sqlParams = append(sqlParams, val) 737 | } else { 738 | tx.Rollback() 739 | return nil, fmt.Errorf("Parameter %s not provided.", param) 740 | } 741 | } 742 | 743 | if statement.Query { 744 | result, err = gosqlcrud.QueryToMaps(tx, statementSQL, sqlParams...) 745 | if err != nil { 746 | tx.Rollback() 747 | return nil, err 748 | } 749 | if statement.Export { 750 | exportedResults[statement.Label] = result 751 | } 752 | } else { 753 | result, err = gosqlcrud.Exec(tx, statementSQL, sqlParams...) 754 | if err != nil { 755 | tx.Rollback() 756 | return nil, err 757 | } 758 | if statement.Export { 759 | exportedResults[statement.Label] = result 760 | } 761 | } 762 | 763 | } 764 | 765 | tx.Commit() 766 | if len(exportedResults) == 0 { 767 | return nil, nil 768 | } 769 | if len(exportedResults) == 1 { 770 | if exportedResult, ok := exportedResults[""]; ok { 771 | return exportedResult, nil 772 | } 773 | } 774 | return exportedResults, nil 775 | } 776 | --------------------------------------------------------------------------------