-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 15d7277
Showing
4 changed files
with
368 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
httpsql.exe | ||
httpsql.zip | ||
config.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
The MIT License (MIT) | ||
|
||
Copyright (c) 2018 Little brother. | ||
|
||
Permission is hereby granted, free of charge, to any person obtaining a copy | ||
of this software and associated documentation files (the "Software"), to deal | ||
in the Software without restriction, including without limitation the rights | ||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
copies of the Software, and to permit persons to whom the Software is | ||
furnished to do so, subject to the following conditions: | ||
|
||
The above copyright notice and this permission notice shall be included in all | ||
copies or substantial portions of the Software. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
Httpsql is a http server to provides a simple way of monitoring SQL databases via a http request by predefined queries. | ||
|
||
### How to build app | ||
1. Download and install [Golang](https://golang.org/dl/) | ||
2. Download dependencies | ||
```bash | ||
go get -v -d ./ | ||
``` | ||
3. Build application | ||
```bash | ||
go build -ldflags="-s -w" | ||
``` | ||
|
||
Also you can download [binary](https://github.com/little-brother/httpsql/releases). | ||
|
||
### How to use | ||
Before continue you must create `config.json` in app folder. Below is an example: | ||
|
||
<pre> | ||
{ | ||
"port": "9000", | ||
"databases": { | ||
|
||
"demo": { | ||
"driver": "mysql", | ||
"dns": "myuser@tcp(192.168.0.101)/mydb", | ||
"metrics": ["now", "count", "minmax", "getbyid"] | ||
}, | ||
|
||
"demo2": { | ||
"driver": "postgres", | ||
"dns": "host=192.168.0.101 user=home password=password dbname=mydb2 sslmode=disable", | ||
"metrics": ["count", "minmax"] | ||
} | ||
}, | ||
|
||
"metrics": { | ||
|
||
"now": { | ||
"query": "select now()", | ||
"description": "Params: none. Returns current date and time." | ||
}, | ||
|
||
"count": { | ||
"query": "select count(1) as count from #table", | ||
"description": "Params: table. Returns row count of table." | ||
}, | ||
|
||
"minmax": { | ||
"query": "select min(#column) min, max(#column) max from #table", | ||
"description": "Params: table, column. Returns max and min value." | ||
}, | ||
|
||
"getbyid": { | ||
"query": "select * from #table where id = $id", | ||
"description": "Params: table, id. Returns row with requested id." | ||
} | ||
} | ||
} | ||
</pre> | ||
|
||
The following links will be available for this configuration: | ||
* `/` returns all database aliases: `demo` and `demo2` | ||
* `/demo` returns all available metrics for database `demo` and their description | ||
* `/demo/now` returns `mydb` date and time | ||
* `/demo/count?table=orders` returns row count for `mydb.orders` | ||
* `/demo/minmax?table=orders&column=price` returns minimal and maximum `price` in `mydb.orders` | ||
* `/demo/getbyid?table=orders&id=10` returns order detail with id = `10` | ||
* `/demo2/count?table=customers` returns customer count in `mydb2.customers` | ||
* `/demo2/minmax?table=...` | ||
|
||
In query `#param` defines a url parameter `param` that value will be substituted directly into the query. To avoid sql injections, all characters except `a-Z0-9_.$` will be removed from value and length is limited to 64 characters. `$param` defines a placeholder parameter and can contains any symbols. | ||
<br> | ||
|
||
Request result is `json` or `text`(csv). By default data format defines by http `Accept` header. You can lock format by adding `json` or `text` to requested url e.g. `/demo2/count?table=customers&text`. | ||
<br> | ||
|
||
| One permanent connection is used for each database. If necessary, the connection will be restored. | | ||
|---| | ||
|
||
### Supported databases | ||
|
||
|DBMS|Driver|Dns example| | ||
|-----|--------|----------| | ||
|MySQL|[mysql](https://github.com/go-sql-driver/mysql)|myuser@tcp(192.168.0.101)/mydb| | ||
|PosgreSQL|[postgres](https://github.com/lib/pq)|host=192.168.0.101 user=home password=password dbname=mydb sslmode=disable| | ||
|MSSQL|[mssql](https://github.com/denisenkom/go-mssqldb)|sqlserver://username:password@host/instance?param1=value¶m2=value<br>sqlserver://username:password@host:port?param1=value¶m2=value| | ||
|ADODB|[adodb](https://github.com/mattn/go-adodb)|Provider=Microsoft.Jet.OLEDB.4.0;Data Source=my.mdb;| | ||
|ODBC|[odbc](https://github.com/alexbrainman/odbc)|Driver={Microsoft ODBC for Oracle};Server=ORACLE8i7;Persist Security Info=False;Trusted_Connection=Yes| | ||
|ClickHouse|[clickhouse](https://github.com/kshvakov/clickhouse)|tcp://127.0.0.1:9000?username=&debug=true| | ||
|Firebird|[firebirdsql](https://github.com/nakagami/firebirdsql)|user:password@servername/foo/bar.fdb| | ||
|SQLite3|[sqlite3](https://github.com/mattn/go-sqlite3)|D:/aaa/bbb/mydb.sqlite| | ||
|
||
> Notice: most databases require additional configuration for remote connections | ||
|
||
You can add other [drivers](https://github.com/golang/go/wiki/SQLDrivers) but some of them requires additional software. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,248 @@ | ||
package main | ||
import ( | ||
"fmt" | ||
"regexp" | ||
"strings" | ||
"net/http" | ||
"encoding/json" | ||
"io/ioutil" | ||
"database/sql" | ||
_ "github.com/go-sql-driver/mysql" | ||
_ "github.com/lib/pq" | ||
_ "github.com/denisenkom/go-mssqldb" | ||
_ "github.com/mattn/go-adodb" | ||
_ "github.com/alexbrainman/odbc" | ||
_ "github.com/kshvakov/clickhouse" | ||
_ "github.com/nakagami/firebirdsql" | ||
_ "github.com/mattn/go-sqlite3" | ||
|
||
// _ "github.com/mattn/go-oci8" | ||
) | ||
|
||
var config Config | ||
var connections map[string]*sql.DB | ||
var reSafe = regexp.MustCompile("[^a-zA-Z0-9_\\.$]+") | ||
|
||
type Config struct { | ||
Port string | ||
Databases map[string]Database | ||
Metrics map[string]Metric | ||
} | ||
|
||
type Database struct { | ||
Driver string | ||
Dns string | ||
Metrics []string | ||
} | ||
|
||
type Metric struct { | ||
Query string | ||
Description string | ||
} | ||
|
||
func sendJson(w http.ResponseWriter, data interface{}) { | ||
jsonData, err := json.Marshal(data) | ||
if err != nil { | ||
http.Error(w, err.Error(), http.StatusInternalServerError) | ||
return | ||
} | ||
|
||
w.Header().Set("Content-Type", "application/json") | ||
w.Write(jsonData) | ||
return | ||
} | ||
|
||
func sendText(w http.ResponseWriter, data []map[string]interface {}, columns []string) { | ||
if len(data) == 0 { | ||
fmt.Fprintf(w, "") | ||
return | ||
} | ||
|
||
text := "" | ||
for i, row := range (data) { | ||
values := make([]string, 0); | ||
for _, col := range(columns) { | ||
if row[col] != nil { | ||
values = append(values, fmt.Sprintf("%v", row[col])) | ||
} else { | ||
values = append(values, "null") | ||
} | ||
} | ||
|
||
text = text + strings.Join(values, ";") | ||
if i != len(data) - 1 { | ||
text = text + "\n" | ||
} | ||
} | ||
|
||
fmt.Fprintf(w, "%v", text) | ||
return | ||
} | ||
|
||
func httpHandler(w http.ResponseWriter, r *http.Request) { | ||
url := strings.Split(r.URL.Path, "/") | ||
if (len(url) < 2) { | ||
http.Error(w, "UNKNOWN", http.StatusInternalServerError) | ||
return | ||
} | ||
|
||
alias := url[1] | ||
metric := "" | ||
if len(url) > 2 { | ||
metric = url[2] | ||
} | ||
|
||
if alias == "" { | ||
keys := []string{} | ||
for k := range config.Databases { | ||
keys = append(keys, k) | ||
} | ||
|
||
sendJson(w, keys) | ||
return | ||
} | ||
|
||
prop, ok := config.Databases[alias]; | ||
if !ok { | ||
http.Error(w, "NOT_FOUND", http.StatusNotFound) | ||
return | ||
} | ||
|
||
if metric == "" { | ||
mdesc := make(map[string]string) | ||
for _, name := range (prop.Metrics) { | ||
m, ok := config.Metrics[name]; | ||
if ok { | ||
mdesc[name] = m.Description | ||
} | ||
} | ||
sendJson(w, mdesc) | ||
return | ||
} | ||
|
||
hasMetric := false | ||
for _, m := range(config.Databases[alias].Metrics) { | ||
hasMetric = hasMetric || m == metric | ||
} | ||
|
||
if !hasMetric { | ||
http.Error(w, "NOT_FOUND", http.StatusNotFound) | ||
return | ||
} | ||
|
||
mprop, ok := config.Metrics[metric]; | ||
if !ok { | ||
http.Error(w, "NOT_FOUND", http.StatusNotFound) | ||
return | ||
} | ||
|
||
var err error | ||
db, ok := connections[alias] | ||
if !ok || db == nil { | ||
db, err = sql.Open(prop.Driver, prop.Dns) | ||
if err != nil { | ||
http.Error(w, "ECONNREFUSED", http.StatusInternalServerError) | ||
fmt.Println(alias +"/" + metric + "\n", err.Error()) | ||
return | ||
} | ||
connections[alias] = db | ||
} else { | ||
err = db.Ping() | ||
if err != nil { | ||
http.Error(w, "ECONNREFUSED", http.StatusInternalServerError) | ||
fmt.Println(alias +"/" + metric + "\n", err.Error()) | ||
defer db.Close() | ||
return | ||
} | ||
} | ||
|
||
query := mprop.Query | ||
var params []interface{} | ||
for param, value := range (r.URL.Query()) { | ||
if len(value) > 0 && len(value[0]) < 64 { | ||
val := reSafe.ReplaceAllString(value[0], "") | ||
query = strings.Replace(query, "#" + param, val, -1) | ||
} | ||
|
||
if len(value) > 0 && strings.Contains(query, "$" + param) { | ||
params = append(params, value[0]); | ||
query = strings.Replace(query, "$" + param, "?", 1) | ||
} | ||
} | ||
|
||
rows, err := db.Query(query, params...) | ||
if err != nil { | ||
http.Error(w, "SQL_ERROR", http.StatusInternalServerError) | ||
fmt.Println(alias +"/" + metric + "\n", "Query: ", query, "\n", "Params: ", params, "\n", err.Error()) | ||
return | ||
} | ||
defer rows.Close() | ||
|
||
columns, err := rows.Columns() | ||
if err != nil { | ||
http.Error(w, err.Error(), http.StatusInternalServerError) | ||
return | ||
} | ||
|
||
count := len(columns) | ||
tableData := make([]map[string]interface{}, 0) | ||
values := make([]interface{}, count) | ||
valuePtrs := make([]interface{}, count) | ||
for rows.Next() { | ||
for i := 0; i < count; i++ { | ||
valuePtrs[i] = &values[i] | ||
} | ||
rows.Scan(valuePtrs...) | ||
entry := make(map[string]interface{}) | ||
for i, col := range columns { | ||
var v interface{} | ||
val := values[i] | ||
b, ok := val.([]byte) | ||
if ok { | ||
v = string(b) | ||
} else { | ||
v = val | ||
} | ||
entry[col] = v | ||
} | ||
tableData = append(tableData, entry) | ||
} | ||
|
||
_, jsonok := r.URL.Query()["json"] | ||
_, textok := r.URL.Query()["text"] | ||
if textok || !jsonok && strings.HasPrefix(r.Header.Get("Accept"), "text") { | ||
sendText(w, tableData, columns) | ||
return | ||
} | ||
|
||
sendJson(w, tableData) | ||
return | ||
} | ||
|
||
func main() { | ||
file, err := ioutil.ReadFile("./config.json") | ||
if err != nil { | ||
fmt.Printf("Couldn't read config.json\n", err.Error()) | ||
return | ||
} | ||
|
||
err = json.Unmarshal(file, &config) | ||
if err != nil { | ||
fmt.Println("Couldn't parse config.json\n", err.Error()) | ||
return | ||
} | ||
|
||
connections = make(map[string]*sql.DB, 0) | ||
|
||
if config.Port == "" { | ||
config.Port = "9000" | ||
} | ||
|
||
http.HandleFunc("/", httpHandler) | ||
fmt.Println("Httpsql running on " + config.Port + " port\n") | ||
err = http.ListenAndServe(":" + config.Port, nil) | ||
if err != nil { | ||
fmt.Println("Couldn't start http server\n", err.Error()) | ||
return | ||
} | ||
} |