From 2d39d4442c1dbc7e002621b3388df783dccad026 Mon Sep 17 00:00:00 2001 From: XD-DENG Date: Tue, 15 Oct 2019 22:48:48 +0800 Subject: [PATCH] 1.0.0-alpha --- .github/workflows/go.yml | 43 +++ .gitignore | 2 + Dockerfile | 30 ++ Jenkinsfile | 31 ++ LICENSE | 201 ++++++++++++ README.md | 95 ++++++ actions.go | 247 +++++++++++++++ actions_test.go | 330 ++++++++++++++++++++ conn/client.go | 34 ++ go.mod | 13 + go.sum | 41 +++ prepare_dev_env.sh | 3 + rediseen.go | 56 ++++ services.go | 127 ++++++++ services_test.go | 651 +++++++++++++++++++++++++++++++++++++++ strings.go | 24 ++ types/types.go | 15 + version.go | 3 + 18 files changed, 1946 insertions(+) create mode 100644 .github/workflows/go.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Jenkinsfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 actions.go create mode 100644 actions_test.go create mode 100644 conn/client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 prepare_dev_env.sh create mode 100644 rediseen.go create mode 100644 services.go create mode 100644 services_test.go create mode 100644 strings.go create mode 100644 types/types.go create mode 100644 version.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..a0e1d57 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,43 @@ +name: Go +on: [push] + +jobs: + build: + name: CI + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macOS-latest] + + steps: + - name: Set up Go 1.12 + uses: actions/setup-go@v1 + with: + go-version: 1.12 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v1 + + - name: Get dependencies + run: | + go get -v -t -d ./... + if [ -f Gopkg.toml ]; then + curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh + dep ensure + fi + + - name: Test + env: + REDISEEN_KEY_PATTERN_EXPOSED: "^key:[.]*" + REDISEEN_KEY_PATTERN_EXPOSE_ALL: false + REDISEEN_REDIS_URI: redis://:@localhost:6400 + REDISEEN_TEST_MODE: true + REDISEEN_DB_EXPOSED: 0-5 + run: go test -v -cover . + + - name: Build + run: | + go build -v . + ./rediseen version + ./rediseen invalid_command diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2a48c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/* +rediseen diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..682ea85 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM golang:1.12.5-alpine3.9 AS builder + +WORKDIR /app +COPY . /app + +RUN apk add --no-cache git gcc +RUN apk add libc-dev + +RUN go build rediseen + +# Unit Test +ENV REDISEEN_REDIS_URI=redis://:@localhost:6400 +ENV REDISEEN_KEY_PATTERN_EXPOSED="^key:[.]*" +ENV REDISEEN_TEST_MODE=true +ENV REDISEEN_DB_EXPOSED=0-5 +RUN go test -cover . + +ENV REDISEEN_REDIS_URI= +ENV REDISEEN_KEY_PATTERN_EXPOSED= +ENV REDISEEN_TEST_MODE= +ENV REDISEEN_DB_EXPOSED= + + +# For smaller image size +# see https://medium.com/@gdiener/how-to-build-a-smaller-docker-image-76779e18d48a +FROM alpine:3.9 +WORKDIR /app +COPY --from=builder /app/rediseen ./rediseen + +CMD ["./rediseen"] \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..4401cd6 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,31 @@ +pipeline { + agent any + + environment { + imageName = "rediseen" + tag = "latest" + } + + stages { + stage('Check CICD Server Status') { + steps { + sh '''uptime''' + + sh '''df -h''' + sh '''docker image ls --all''' + + sh '''docker image prune -f''' + + sh '''df -h''' + sh '''docker image ls --all''' + } + } + stage('Build') { + steps { + script { + sh '''docker build -t ${imageName}:${tag} .''' + } + } + } + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e39d33d --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2019 Xiaodong DENG + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..1601a8b --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# rediseen + +Start a REST-like API service for your Redis database, without writing a single line of code. + + +## 1. Quick Start + +Let's assume that your Redis database URI is `redis://:@localhost:6379`, and you want to expose keys with prefix `key:` in logical database `0`. + +```bash +# installation +go install . # or "brew install XD-DENG/rediseen/formula" + +# Configuration +export REDISEEN_REDIS_URI="redis://:@localhost:6379" +export REDISEEN_DB_EXPOSED=0 +export REDISEEN_KEY_PATTERN_EXPOSED="^key:([0-9a-z]+)" + +# Start the service +rediseen start +``` + +Now you should be able to query against the database, like `http://localhost:8000/0/key:1`. + +For more details, please refer to the rest of this README documentation. + + + +## 2. Usage + + +### 2.1 How to Install + +- **Install via `Homebrew`** + +```bash +brew install XD-DENG/rediseen/formula + +``` +- **Install from source** (with Go 1.12 or above installed) + +```bash +go install . +``` + + +### 2.2 How to Configure + +Configuration is done via **environment variables**. + +| Item | Description | Remark | +| --- | --- | --- | +| `REDISEEN_REDIS_URI` | URI of your Redis database, e.g. `redis://:@localhost:6379` | Compulsory | +| `REDISEEN_PORT` | Port of the service. Default port is 8000. | Optional | +| `REDISEEN_DB_EXPOSED` | Redis logical database(s) to expose.

E.g., `0`, `0;3;9`, `0-9;15`, or `*` (expose all logical databases) | Compulsory | +| `REDISEEN_KEY_PATTERN_EXPOSED` | Regular expression pattern, representing the name pattern of keys that you intend to expose.

For example, `user:([0-9a-z/.]+)\|^info:([0-9a-z/.]+)` exposes keys like `user:1`, `user:x1`, `testuser:1`, `info:1`, etc. | | +| `REDISEEN_KEY_PATTERN_EXPOSE_ALL` | If you intend to expose ***all*** your keys, set `REDISEEN_KEY_PATTERN_EXPOSE_ALL` to `true`. | `REDISEEN_KEY_PATTERN_EXPOSED` can only be empty (or not set) if you have set `REDISEEN_KEY_PATTERN_EXPOSE_ALL` to `true`. | +| `REDISEEN_TEST_MODE` | Set to `true` to skip Redis connection validation for unit tests. | For Dev Only | + + +### 2.3 How to Start the Service + +Run command below, + +```bash +rediseen start +``` + +Then you can access the service at +- `http://://` +- `http://:///` + + +### 2.4 How to Consume the Service + +#### 2.4.1 `//` + +| Data Type | Underlying Redis Command | +| --- | --- | +| STRING | `GET(key)` | +| LIST | `LRANGE(key, 0, -1)` | +| SET | `SMEMBERS(key)` | +| HASH | `HGETALL(key)` | +| ZSET | `ZRANGE(key, 0, -1)` | + + +#### 2.4.2 `///` + +| Data Type | Usage | Return Value | +| --- | --- | --- | +| STRING | `///` | ``-th character in the string | +| LIST | `///` | ``-th element in the list | +| SET | `///` | if `` is member of the set | +| HASH | `///` | value of hash `` in the hash | +| ZSET | `///` | index of `` in the sorted set | diff --git a/actions.go b/actions.go new file mode 100644 index 0000000..291b84f --- /dev/null +++ b/actions.go @@ -0,0 +1,247 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/go-redis/redis" + "log" + "net/http" + "os" + "rediseen/conn" + "rediseen/types" + "regexp" + "strconv" + "strings" +) + +var dbExposedMap = make(map[int]bool) +var regexpKeyPatternAllowed *regexp.Regexp + +// Check Configurations, and stop proceeding if any configuration is missing or conflicting +func configCheck() error { + redisUri := os.Getenv("REDISEEN_REDIS_URI") + if redisUri == "" { + return errors.New("No valid Redis URI is provided " + + "(via environment variable REDISEEN_REDIS_URI)") + } + + _, err := redis.ParseURL(os.Getenv("REDISEEN_REDIS_URI")) + if err != nil { + return errors.New(fmt.Sprintf("Redis URI provided "+ + "(via environment variable REDISEEN_REDIS_URI)"+ + "is not valid (details: %s)", err.Error())) + } + + var dbExposed = os.Getenv("REDISEEN_DB_EXPOSED") + if dbExposed == "" { + return errors.New("REDISEEN_DB_EXPOSED is not configured") + } + dbConfigCheckResult, err := validateDbExposeConfig(dbExposed) + if dbConfigCheckResult == false { + var errMsg strings.Builder + errMsg.WriteString("REDISEEN_DB_EXPOSED provided can not be parsed properly") + if err != nil { + errMsg.WriteString(fmt.Sprintf(" (details: %s)", err.Error())) + } + return errors.New(errMsg.String()) + } + dbExposedMap = parseDbExposed(os.Getenv("REDISEEN_DB_EXPOSED")) + + var keyPatternAllowed = os.Getenv("REDISEEN_KEY_PATTERN_EXPOSED") + var keyPatternAllowAll = os.Getenv("REDISEEN_KEY_PATTERN_EXPOSE_ALL") + + if keyPatternAllowed == "" && keyPatternAllowAll != "true" { + strError := "You have not specified any key pattern to allow being accessed " + + "(environment variable REDISEEN_KEY_PATTERN_EXPOSED)\n" + + " To allow ALL keys to be accessed, " + + "set environment variable REDISEEN_KEY_PATTERN_EXPOSE_ALL=true" + return errors.New(strError) + } + + if keyPatternAllowAll == "true" { + if keyPatternAllowed != "" { + return errors.New("You have specified both REDISEEN_KEY_PATTERN_EXPOSED " + + "and REDISEEN_KEY_PATTERN_EXPOSE_ALL=true, which is conflicting.") + } else { + log.Println("[WARNING] You are exposing ALL keys.") + } + } + + if keyPatternAllowed != "" { + regexpKeyPatternAllowed, err = regexp.Compile(keyPatternAllowed) + if err != nil { + return errors.New(fmt.Sprintf("REDISEEN_KEY_PATTERN_EXPOSED can not be "+ + "compiled as regular expression. Details: %s\n", err.Error())) + } + log.Println(fmt.Sprintf("[INFO] You are exposing keys of pattern `%s`", keyPatternAllowed)) + } + + err = conn.ClientPing() + if err != nil { + return errors.New(fmt.Sprintf("Initial talking to Redis failed. "+ + "Please check the URI provided. Details: %s\n", err.Error())) + } + + return nil +} + +// validate if the string given is legal +func validateDbExposeConfig(configDbExposed string) (bool, error) { + // case-1: "*" + if configDbExposed == "*" { + log.Println("[WARNING] You are exposing ALL logical databases.") + return true, nil + } + + log.Println(fmt.Sprintf("[INFO] You are exposing logical database(s) `%s`", configDbExposed)) + + // case-2: "0" or "18" + patternCheck1, err := regexp.MatchString("^[0-9]+$", configDbExposed) + if err != nil { + return false, err + } + // case-3: "0-10" or "0;0-10" or "1-10;13" + patternCheck2, err := regexp.MatchString("(^[0-9]+)([0-9;-]*)([0-9]+$)", configDbExposed) + if err != nil { + return false, err + } + + if !patternCheck1 && !patternCheck2 { + return false, errors.New("illegal pattern") + } + + // If multiple values are provided (semicolon-separated), check value by value + parts := strings.Split(configDbExposed, ";") + for _, p := range parts { + subPatternCheck1, _ := regexp.MatchString("^[0-9]+$", p) + subPatternCheck2, _ := regexp.MatchString("(^[0-9]+)(-)([0-9]+$)", p) + + if !subPatternCheck1 && !subPatternCheck2 { + return false, errors.New("illegal pattern") + } + } + + return true, nil +} + +// provide strings like "0;1;3;5" or "0;9-14;5" into a map for later querying +// store as a map to achieve O(1) search complexity +func parseDbExposed(configDbExposed string) map[int]bool { + result := make(map[int]bool) + parts := strings.Split(configDbExposed, ";") + + for _, p := range parts { + patternCheckResult1, _ := regexp.MatchString("^[0-9]+$", p) + patternCheckResult2, _ := regexp.MatchString("(^[0-9]+)(-)([0-9]+$)", p) + + if patternCheckResult1 { + dbInt, _ := strconv.Atoi(p) + result[dbInt] = true + } else if patternCheckResult2 { + temp := strings.Split(p, "-") + dbInt1, _ := strconv.Atoi(temp[0]) + dbInt2, _ := strconv.Atoi(temp[1]) + for i := dbInt1; i <= dbInt2; i++ { + result[i] = true + } + } + } + + return result +} + +//Check if db given by user is forbidden from being exposed +func dbCheck(db int) bool { + if os.Getenv("REDISEEN_DB_EXPOSED") == "*" { + return true + } + + _, ok := dbExposedMap[db] + if !ok { + return false + } else { + return true + } +} + +// Check if a string matches a pre-specified `keyPatternAllowed` (returns Boolean) +func keyPatternCheck(key string) bool { + return regexpKeyPatternAllowed.MatchString(key) +} + +// Handle requests to different Redis Data Types, and return values correspondingly +func get(client *redis.Client, res http.ResponseWriter, key string, indexOrField string) { + + var js []byte + var index int64 + var field string + var value interface{} + + keyType, err := client.Type(key).Result() + + if keyType == "string" || keyType == "list" { + index, _ = strconv.ParseInt(indexOrField, 10, 64) + } else { + field = indexOrField + } + + if indexOrField == "" { + switch keyType { + case "string": + value, err = client.Get(key).Result() + case "list": + value, err = client.LRange(key, 0, -1).Result() + case "set": + value, err = client.SMembers(key).Result() + case "hash": + value, err = client.HGetAll(key).Result() + case "zset": + //TODO: a simple implementation given methods on sorted set can be very complicated + value, err = client.ZRange(key, 0, -1).Result() + default: + err = errors.New(strNotImplemented) + } + } else { + switch keyType { + case "string": + if index == 0 && indexOrField != "0" { + err = errors.New(strWrongTypeForIndexField) + } else { + value, err = client.GetRange(key, index, index).Result() + } + case "list": + if index == 0 && indexOrField != "0" { + err = errors.New(strWrongTypeForIndexField) + } else { + value, err = client.LIndex(key, index).Result() + } + case "set": + value, err = client.SIsMember(key, field).Result() + case "hash": + value, err = client.HGet(key, field).Result() + case "zset": + value, err = client.ZRank(key, field).Result() + default: + err = errors.New(strNotImplemented) + } + } + + if err != nil { + if strings.Contains(err.Error(), strNotImplemented) { + res.WriteHeader(http.StatusNotImplemented) + } else if strings.Contains(err.Error(), strWrongTypeForIndexField) { + res.WriteHeader(http.StatusBadRequest) + } else { + res.WriteHeader(http.StatusNotFound) + } + js, _ = json.Marshal(types.ErrorType{Error: err.Error()}) + res.Write(js) + } else { + var response types.ResponseType + response = types.ResponseType{ValueType: keyType, Value: value} + + js, _ = json.Marshal(response) + res.Write(js) + } +} diff --git a/actions_test.go b/actions_test.go new file mode 100644 index 0000000..7e63bdc --- /dev/null +++ b/actions_test.go @@ -0,0 +1,330 @@ +package main + +import ( + "fmt" + "os" + "strings" + "testing" +) + +func Test_configCheck_no_redis_uri(t *testing.T) { + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", "") + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + err := configCheck() + + if err == nil { + t.Error("Expecting error but got nil") + } + + if !strings.Contains(err.Error(), "No valid Redis URI is provided") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } +} + +func Test_configCheck_invalid_redis_uri(t *testing.T) { + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", "mysql://a:b@localhost:8888/db") + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + err := configCheck() + + if err == nil { + t.Error("Expecting error but got nil") + } + + if !strings.Contains(err.Error(), "is not valid") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } +} + +func Test_configCheck_no_db_exposed(t *testing.T) { + + originalDbExposed := os.Getenv("REDISEEN_DB_EXPOSED") + os.Setenv("REDISEEN_DB_EXPOSED", "") + defer os.Setenv("REDISEEN_DB_EXPOSED", originalDbExposed) + + err := configCheck() + + if err == nil { + t.Error("Expecting error but got nil") + } + + if !strings.Contains(err.Error(), "REDISEEN_DB_EXPOSED is not configured") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } +} + +func Test_configCheck_invalid_db_exposed_1(t *testing.T) { + + originalDbExposed := os.Getenv("REDISEEN_DB_EXPOSED") + os.Setenv("REDISEEN_DB_EXPOSED", "-1") + defer os.Setenv("REDISEEN_DB_EXPOSED", originalDbExposed) + + err := configCheck() + + if err == nil { + t.Error("Expecting error but got nil") + } + + if !strings.Contains(err.Error(), "REDISEEN_DB_EXPOSED provided can not be parsed properly") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } +} + +func Test_configCheck_invalid_db_exposed_2(t *testing.T) { + + originalDbExposed := os.Getenv("REDISEEN_DB_EXPOSED") + os.Setenv("REDISEEN_DB_EXPOSED", "1;-2;10") + defer os.Setenv("REDISEEN_DB_EXPOSED", originalDbExposed) + + err := configCheck() + + if err == nil { + t.Error("Expecting error but got nil") + } + + if !strings.Contains(err.Error(), "REDISEEN_DB_EXPOSED provided can not be parsed properly") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } +} + +func Test_configCheck_no_key_pattern_specified(t *testing.T) { + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", "redis://:@localhost:6379") + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + originalKeyPatternAllowed := os.Getenv("REDISEEN_KEY_PATTERN_EXPOSED") + os.Setenv("REDISEEN_KEY_PATTERN_EXPOSED", "") + defer os.Setenv("REDISEEN_KEY_PATTERN_EXPOSED", originalKeyPatternAllowed) + + originalKeyPatternAllowAll := os.Getenv("REDISEEN_KEY_PATTERN_EXPOSE_ALL") + os.Setenv("REDISEEN_KEY_PATTERN_EXPOSE_ALL", "") + defer os.Setenv("REDISEEN_KEY_PATTERN_EXPOSE_ALL", originalKeyPatternAllowAll) + + err := configCheck() + + if err == nil { + t.Error("Expecting error but got nil") + } + + if !strings.Contains(err.Error(), "You have not specified any key pattern to allow being accessed") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } + + if !strings.Contains(err.Error(), "To allow ALL keys to be accessed,") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } +} + +func Test_configCheck_conflicting_key_pattern_specified(t *testing.T) { + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", "redis://:@localhost:6379") + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + originalKeyPatternAllowed := os.Getenv("REDISEEN_KEY_PATTERN_EXPOSED") + os.Setenv("REDISEEN_KEY_PATTERN_EXPOSED", "^key:[.]*") + defer os.Setenv("REDISEEN_KEY_PATTERN_EXPOSED", originalKeyPatternAllowed) + + originalKeyPatternAllowAll := os.Getenv("REDISEEN_KEY_PATTERN_EXPOSE_ALL") + os.Setenv("REDISEEN_KEY_PATTERN_EXPOSE_ALL", "true") + defer os.Setenv("REDISEEN_KEY_PATTERN_EXPOSE_ALL", originalKeyPatternAllowAll) + + err := configCheck() + + if err == nil { + t.Error("Expecting error but got nil") + } + + if !strings.Contains(err.Error(), "You have specified both") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } + + if !strings.Contains(err.Error(), "which is conflicting.") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } +} + +func Test_configCheck_bad_regex(t *testing.T) { + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", "redis://:@localhost:6379") + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + originalKeyPatternAllowed := os.Getenv("REDISEEN_KEY_PATTERN_EXPOSED") + os.Setenv("REDISEEN_KEY_PATTERN_EXPOSED", "^key:[.*") + defer os.Setenv("REDISEEN_KEY_PATTERN_EXPOSED", originalKeyPatternAllowed) + + err := configCheck() + + if err == nil { + t.Error("Expecting error but got nil") + } + + if !strings.Contains(err.Error(), "REDISEEN_KEY_PATTERN_EXPOSED can not be compiled as regular expression") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } +} + +func Test_configCheck_good_config(t *testing.T) { + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", "redis://:@localhost:6379") + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + originalKeyPatternAllowed := os.Getenv("REDISEEN_KEY_PATTERN_EXPOSED") + os.Setenv("REDISEEN_KEY_PATTERN_EXPOSED", "^key:[.]*") + defer os.Setenv("REDISEEN_KEY_PATTERN_EXPOSED", originalKeyPatternAllowed) + + err := configCheck() + + if err != nil { + t.Error("Not expecting error but got error") + } +} + +func Test_configCheck_connection_failure(t *testing.T) { + + originalTestMode := os.Getenv("REDISEEN_TEST_MODE") + os.Setenv("REDISEEN_TEST_MODE", "") + defer os.Setenv("REDISEEN_TEST_MODE", originalTestMode) + + err := configCheck() + + if err == nil { + t.Error("Expecting error but got nil") + } + + if !strings.Contains(err.Error(), "Initial talking to Redis failed.") { + t.Error(fmt.Sprintf("Error contents `%s` is not what's expected", err.Error())) + } +} + +func Test_parseDbExposed_1(t *testing.T) { + + result := parseDbExposed("1;3;9;100") + + for _, i := range []int{1, 3, 9, 100} { + _, ok := result[i] + if !ok { + t.Error("parsing wrongly") + } + } + + for _, i := range []int{2, 8, 99, 101} { + _, ok := result[i] + if ok { + t.Error("parsing wrongly") + } + } +} + +func Test_parseDbExposed_2(t *testing.T) { + + result := parseDbExposed("1;3-9;12-15;100") + + for _, i := range []int{1, 3, 4, 5, 6, 7, 8, 9, 12, 13, 14, 15, 100} { + _, ok := result[i] + if !ok { + t.Error("parsing wrongly") + } + } + + for _, i := range []int{2, 10, 16, 99, 101} { + _, ok := result[i] + if ok { + t.Error("parsing wrongly") + } + } +} + +func Test_parseDbExposed_3(t *testing.T) { + + result := parseDbExposed("0") + + _, ok := result[0] + if !ok { + t.Error("parsing wrongly") + } + + for _, i := range []int{2, 10, 16, 99, 101, 1, 3, 4, 5, 6, 7, 8, 9, 12, 13, 14, 15, 100} { + _, ok := result[i] + if ok { + t.Error("parsing wrongly") + } + } +} + +func Test_validateDbExposeConfig(t *testing.T) { + var ok bool + var err error + + ok, err = validateDbExposeConfig("*") + if err != nil || ok == false { + t.Error("checkDbExpose() failed for '*'") + } + + ok, err = validateDbExposeConfig("8") + if err != nil || ok == false { + t.Error("checkDbExpose() failed for '8'") + } + + ok, err = validateDbExposeConfig("1-10") + if err != nil || ok == false { + t.Error("checkDbExpose() failed for '1-10'") + } + + ok, err = validateDbExposeConfig("1;3;8") + if err != nil || ok == false { + t.Error("checkDbExpose() failed for '1;3;8'") + } + + ok, err = validateDbExposeConfig("1;3-7;18") + if err != nil || ok == false { + t.Error("checkDbExpose() failed for '1;3-7;18'") + } + + ok, err = validateDbExposeConfig("-1;18") + if err == nil || ok == true { + t.Error("checkDbExpose() passed WRONGLY for '-1;18'") + } + + ok, err = validateDbExposeConfig("a;18") + if err == nil || ok == true { + t.Error("checkDbExpose() passed WRONGLY for 'a;18'") + } +} + +func Test_dbCheck(t *testing.T) { + // Test Environment Variable: REDISEEN_DB_EXPOSED=0-5 + + for i := 0; i <= 5; i++ { + if dbCheck(i) == false { + t.Error("something is wrong with dbCheck()") + } + } + + for _, i := range []int{6, 10, 8, 16, 99, 101} { + if dbCheck(i) == true { + t.Error("something is wrong with dbCheck()") + } + } +} + +func Test_dbCheck_expose_all(t *testing.T) { + + originalDbExposed := os.Getenv("REDISEEN_DB_EXPOSED") + os.Setenv("REDISEEN_DB_EXPOSED", "*") + defer os.Setenv("REDISEEN_DB_EXPOSED", originalDbExposed) + + for _, i := range []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} { + if dbCheck(i) == false { + t.Error("something is wrong with dbCheck()") + } + } +} diff --git a/conn/client.go b/conn/client.go new file mode 100644 index 0000000..4b31175 --- /dev/null +++ b/conn/client.go @@ -0,0 +1,34 @@ +package conn + +import ( + "github.com/go-redis/redis" + "os" +) + +// Prepare a Redis client +// Only Redis DB is needed, as all other information will be provided via configuration +func Client(db int) *redis.Client { + parsedUri, _ := redis.ParseURL(os.Getenv("REDISEEN_REDIS_URI")) + + client := redis.NewClient(&redis.Options{ + Addr: parsedUri.Addr, + Password: parsedUri.Password, + DB: db, + }) + + return client +} + +// Check the user-specified `REDISEEN_REDIS_URI` (using default db 0) +func ClientPing() error { + client := Client(0) + defer client.Close() + + if os.Getenv("REDISEEN_TEST_MODE") != "true" { + pingResult, err := client.Ping().Result() + if pingResult != "PONG" { + return err + } + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9b22266 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module rediseen + +go 1.12 + +require ( + github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6 // indirect + github.com/alicebob/miniredis v2.5.0+incompatible + github.com/go-redis/redis v6.15.5+incompatible + github.com/gomodule/redigo v2.0.0+incompatible // indirect + github.com/onsi/ginkgo v1.10.1 // indirect + github.com/onsi/gomega v1.7.0 // indirect + github.com/yuin/gopher-lua v0.0.0-20190514113301-1cd887cd7036 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a860345 --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6 h1:45bxf7AZMwWcqkLzDAQugVEwedisr5nRJ1r+7LYnv0U= +github.com/alicebob/gopher-json v0.0.0-20180125190556-5a6b3ba71ee6/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis v2.5.0+incompatible h1:yBHoLpsyjupjz3NL3MhKMVkR41j82Yjf3KFv7ApYzUI= +github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-redis/redis v6.15.5+incompatible h1:pLky8I0rgiblWfa8C1EV7fPEUv0aH6vKRaYHc/YRHVk= +github.com/go-redis/redis v6.15.5+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/yuin/gopher-lua v0.0.0-20190514113301-1cd887cd7036 h1:1b6PAtenNyhsmo/NKXVe34h7JEZKva1YB/ne7K7mqKM= +github.com/yuin/gopher-lua v0.0.0-20190514113301-1cd887cd7036/go.mod h1:gqRgreBUhTSL0GeU64rtZ3Uq3wtjOa/TB2YfrtkCbVQ= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e h1:o3PsSEY8E4eXWkXrIP9YJALUkVZqzHJT5DOasTyn8Vs= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/prepare_dev_env.sh b/prepare_dev_env.sh new file mode 100644 index 0000000..d7b6d8c --- /dev/null +++ b/prepare_dev_env.sh @@ -0,0 +1,3 @@ +export REDISEEN_KEY_PATTERN_EXPOSED="a:([0-9a-z/.]+)|^b:([0-9a-z/.]+)" +export REDISEEN_DB_EXPOSED="0" +export REDISEEN_REDIS_URI="redis://:@localhost:6379" \ No newline at end of file diff --git a/rediseen.go b/rediseen.go new file mode 100644 index 0000000..7960c08 --- /dev/null +++ b/rediseen.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" +) + +const defaultPort = "8000" + +func main() { + + if len(os.Args) != 2 { + fmt.Println(strLogo) + fmt.Println(strHeader) + fmt.Println(strUsage) + os.Exit(0) + } + + var command = os.Args[1] + + switch command { + case "start": + + fmt.Println(strLogo) + fmt.Println(strHeader) + + err := configCheck() + if err != nil { + fmt.Println("[ERROR] " + err.Error()) + return + } + + http.HandleFunc("/", service) + + port := os.Getenv("REDISEEN_PORT") + if port == "" { + port = defaultPort + } + log.Printf("Running with port %s", port) + serve := http.ListenAndServe(":"+port, nil) + if serve != nil { + panic(serve) + } + case "help": + fmt.Println(strLogo) + fmt.Println(strHelpDoc) + case "version": + fmt.Println(rediseenVersion) + default: + fmt.Println(strLogo) + fmt.Println(strHeader) + fmt.Println(strUsage) + } +} diff --git a/services.go b/services.go new file mode 100644 index 0000000..1c61509 --- /dev/null +++ b/services.go @@ -0,0 +1,127 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "rediseen/conn" + "rediseen/types" + "regexp" + "strconv" + "strings" +) + +func service(res http.ResponseWriter, req *http.Request) { + var js []byte + + if req.Method != "GET" { + res.WriteHeader(http.StatusMethodNotAllowed) + js, _ = json.Marshal(types.ErrorType{Error: fmt.Sprintf("Method %s is not allowed", req.Method)}) + res.Write(js) + return + } + + res.Header().Set("Content-Type", "application/json") + + // Process URL Path into detailed information, like DB and Key + arguments := strings.Split(req.URL.Path, "/") + + if strings.HasSuffix(req.URL.Path, "/") || len(arguments) < 3 { + res.WriteHeader(http.StatusBadRequest) + js, _ = json.Marshal(types.ErrorType{Error: "Usage: /db/key_pattern or /db/key_pattern/"}) + res.Write(js) + return + } + + var rawDb string + var key string + var index string + + rawDb = arguments[1] + db, err := strconv.Atoi(rawDb) + + // deal with situation where key contains "/" + if len(arguments) == 3 { + key = arguments[2] + } else { + restPath := strings.Join(arguments[2:], "/") + countBacktick := strings.Count(restPath, "`") + if countBacktick > 0 && countBacktick%2 == 0 { + if restPath[0] == '`' && restPath[len(restPath)-1] == '`' { + key = restPath[1:(len(restPath) - 1)] + } else { + p := regexp.MustCompile("`(?P.+)`/(?P.+)") + key = p.FindStringSubmatch(restPath)[1] + index = p.FindStringSubmatch(restPath)[2] + } + } else { + if restPath[0] == '`' && restPath[len(restPath)-1] == '`' { + key = restPath[1:(len(restPath) - 1)] + } else { + p := regexp.MustCompile("(?P.+)/(?P.+)") + key = p.FindStringSubmatch(restPath)[1] + index = p.FindStringSubmatch(restPath)[2] + } + } + } + + log.Printf("Request Path: '%s'\n", req.URL.Path) + if err != nil { + res.WriteHeader(http.StatusBadRequest) + js, _ = json.Marshal(types.ErrorType{Error: "Provide an integer for DB"}) + res.Write(js) + return + } + + if !dbCheck(db) { + res.WriteHeader(http.StatusForbidden) + js, _ = json.Marshal(types.ErrorType{Error: fmt.Sprintf("DB %d is not exposed", db)}) + res.Write(js) + return + } + + if !keyPatternCheck(key) { + res.WriteHeader(http.StatusForbidden) + js, _ = json.Marshal(types.ErrorType{Error: "Key pattern is forbidden from access"}) + res.Write(js) + return + } + + client := conn.Client(db) + defer client.Close() + + // Check if key exists, meanwhile check Redis connection + keyExists, err := client.Exists(key).Result() + if err != nil { + res.WriteHeader(http.StatusInternalServerError) + if strings.Contains(err.Error(), "connection refused") { + js, _ = json.Marshal(types.ErrorType{Error: "Connection to Redis is refused."}) + } else { + js, _ = json.Marshal(types.ErrorType{Error: err.Error()}) + } + res.Write(js) + return + } + + if keyExists == 0 { + res.WriteHeader(http.StatusNotFound) + js, _ = json.Marshal(types.ErrorType{Error: "Key provided does not exist."}) + } else { + var logMsg strings.Builder + logMsg.WriteString("Submit query for: db ") + logMsg.WriteString(strconv.Itoa(db)) + logMsg.WriteString(", key `") + logMsg.WriteString(key) + logMsg.WriteString("`") + if index != "" { + logMsg.WriteString(", index/field `") + logMsg.WriteString(index) + logMsg.WriteString("`") + } + + log.Println(logMsg.String()) + get(client, res, key, index) + } + res.Write(js) +} diff --git a/services_test.go b/services_test.go new file mode 100644 index 0000000..0e3786c --- /dev/null +++ b/services_test.go @@ -0,0 +1,651 @@ +package main + +import ( + "encoding/json" + "fmt" + "github.com/alicebob/miniredis" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "rediseen/types" + "testing" +) + +func compareAndShout(t *testing.T, expected interface{}, actual interface{}) { + if expected != actual { + t.Error("Expecting\n", expected, "\ngot\n", actual) + } +} + +func Test_service_wrong_usage(t *testing.T) { + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + expectedCode := 400 + expectedError := "Usage: /db/key_pattern or /db/key_pattern/" + var res *http.Response + + for _, suffix := range []string{"/0", "/0/", "/0/key:1/", "/0/key:1/1/"} { + res, _ = http.Get(s.URL + suffix) + + if res.StatusCode != expectedCode { + t.Error("Expecting\n", expectedCode, "\ngot\n", res.StatusCode) + } + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ErrorType + json.Unmarshal([]byte(resultStr), &result) + + compareAndShout(t, expectedError, result.Error) + } +} + +func Test_service_non_integer_db_provided(t *testing.T) { + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/a/key") + + expectedCode := 400 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ErrorType + json.Unmarshal([]byte(resultStr), &result) + + expectedError := "Provide an integer for DB" + compareAndShout(t, expectedError, result.Error) +} + +func Test_service_redis_conn_refused(t *testing.T) { + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1") + + expectedCode := 500 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ErrorType + json.Unmarshal([]byte(resultStr), &result) + + expectedError := "Connection to Redis is refused." + compareAndShout(t, expectedError, result.Error) +} + +func Test_service_non_existent_key(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s1 := httptest.NewServer(http.HandlerFunc(service)) + defer s1.Close() + + res, _ := http.Get(s1.URL + "/0/key:1") + + expectedCode := 404 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ErrorType + json.Unmarshal([]byte(resultStr), &result) + + expectedError := "Key provided does not exist." + compareAndShout(t, expectedError, result.Error) +} + +func Test_service_string_type(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.Set("key:1", "hi") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ResponseType + json.Unmarshal([]byte(resultStr), &result) + + compareAndShout(t, "string", result.ValueType) + compareAndShout(t, "hi", result.Value) +} + +func Test_service_string_check_by_index(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.Set("key:1", "Developer") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1/0") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ResponseType + json.Unmarshal([]byte(resultStr), &result) + + compareAndShout(t, "string", result.ValueType) + compareAndShout(t, "D", result.Value) + + res, _ = http.Get(s.URL + "/0/key:1/4") + + expectedCode = 200 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ = ioutil.ReadAll(res.Body) + res.Body.Close() + + json.Unmarshal([]byte(resultStr), &result) + + compareAndShout(t, "string", result.ValueType) + compareAndShout(t, "l", result.Value) +} + +func Test_service_string_check_by_index_wrong_index(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.Set("key:1", "Developer") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1/x") + + compareAndShout(t, 400, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ErrorType + json.Unmarshal([]byte(resultStr), &result) + + expectedError := strWrongTypeForIndexField + compareAndShout(t, expectedError, result.Error) +} + +func Test_service_string_type_with_slash_in_key(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.Set("key:/1", "hi") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/`key:/1`") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ResponseType + json.Unmarshal([]byte(resultStr), &result) + + compareAndShout(t, "string", result.ValueType) + compareAndShout(t, "hi", result.Value) +} + +func Test_service_string_type_with_slash_and_backtick_in_key(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.Set("key:`/1", "hi") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/`key:`/1`") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ResponseType + json.Unmarshal([]byte(resultStr), &result) + + compareAndShout(t, "string", result.ValueType) + compareAndShout(t, "hi", result.Value) +} + +func Test_service_string_type_db_no_access(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + // env var set for the test is REDISEEN_DB_EXPOSED=0-5 + res, _ := http.Get(s.URL + "/10/key:1") + + expectedCode := 403 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ErrorType + json.Unmarshal([]byte(resultStr), &result) + + expectedError := "DB 10 is not exposed" + compareAndShout(t, expectedError, result.Error) +} + +func Test_service_string_type_key_no_access(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + //env var set for the test is REDISEEN_KEY_PATTERN_EXPOSED=^key:[.]* + mr.Set("id:1", "hi") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/id:1") + + expectedCode := 403 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ErrorType + json.Unmarshal([]byte(resultStr), &result) + + expectedError := "Key pattern is forbidden from access" + compareAndShout(t, expectedError, result.Error) +} + +func Test_service_list_key(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.Lpush("key:1", "hello") + mr.Lpush("key:1", "world") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult := `{"type":"list","value":["world","hello"]}` + compareAndShout(t, expectedResult, string(result)) +} + +func Test_service_list_key_check_by_index(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.Lpush("key:1", "hello") + mr.Lpush("key:1", "world") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1/0") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult := `{"type":"list","value":"world"}` + compareAndShout(t, expectedResult, string(result)) + + res, _ = http.Get(s.URL + "/0/key:1/1") + + expectedCode = 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ = ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult = `{"type":"list","value":"hello"}` + compareAndShout(t, expectedResult, string(result)) + + // Check wrong type for index + res, _ = http.Get(s.URL + "/0/key:1/a") + + expectedCode = 400 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ = ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult = fmt.Sprintf(`{"error":"%s"}`, strWrongTypeForIndexField) + compareAndShout(t, expectedResult, string(result)) +} + +func Test_service_set(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.SetAdd("key:1", "hello") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult := `{"type":"set","value":["hello"]}` + compareAndShout(t, expectedResult, string(result)) +} + +func Test_service_set_check_by_index(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.SetAdd("key:1", "hello") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1/hello") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult := `{"type":"set","value":true}` + compareAndShout(t, expectedResult, string(result)) + + res, _ = http.Get(s.URL + "/0/key:1/world") + + expectedCode = 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ = ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult = `{"type":"set","value":false}` + compareAndShout(t, expectedResult, string(result)) +} + +func Test_service_hash(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.HSet("key:1", "role", "developer") + mr.HSet("key:1", "id", "1") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult := `{"type":"hash","value":{"id":"1","role":"developer"}}` + compareAndShout(t, expectedResult, string(result)) +} + +func Test_service_hash_check_by_index(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.HSet("key:1", "role", "developer") + mr.HSet("key:1", "id", "1") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:1/role") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult := `{"type":"hash","value":"developer"}` + compareAndShout(t, expectedResult, string(result)) + + res, _ = http.Get(s.URL + "/0/key:1/id") + + expectedCode = 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ = ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult = `{"type":"hash","value":"1"}` + compareAndShout(t, expectedResult, string(result)) +} + +func Test_service_zset(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.ZAdd("key:set", 100, "developer") + mr.ZAdd("key:set", 0, "bluffer") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:set") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult := `{"type":"zset","value":["bluffer","developer"]}` + compareAndShout(t, expectedResult, string(result)) +} + +func Test_service_zset_check_by_field(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.ZAdd("key:set", 200, "Mr.X") + mr.ZAdd("key:set", 100, "developer") + mr.ZAdd("key:set", 0, "bluffer") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + res, _ := http.Get(s.URL + "/0/key:set/developer") + + expectedCode := 200 + compareAndShout(t, expectedCode, res.StatusCode) + + result, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + expectedResult := `{"type":"zset","value":1}` + compareAndShout(t, expectedResult, string(result)) +} + +func Test_service_delete_not_allowed(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.Set("key:1", "hello") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + client := &http.Client{} + req, _ := http.NewRequest("DELETE", s.URL+"/0/key:1", nil) + res, _ := client.Do(req) + + expectedCode := 405 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ErrorType + json.Unmarshal([]byte(resultStr), &result) + + expectedError := "Method DELETE is not allowed" + compareAndShout(t, expectedError, result.Error) +} + +// Request method will happen first, so whatever method other than GET should always get rejected +// Checks like key pattern check should not even happen +func Test_service_delete_not_allowed_no_access(t *testing.T) { + + mr, _ := miniredis.Run() + defer mr.Close() + + mr.Set("id:1", "hello") + + originalRedisUri := os.Getenv("REDISEEN_REDIS_URI") + os.Setenv("REDISEEN_REDIS_URI", fmt.Sprintf("redis://:@%s", mr.Addr())) + defer os.Setenv("REDISEEN_REDIS_URI", originalRedisUri) + + s := httptest.NewServer(http.HandlerFunc(service)) + defer s.Close() + + client := &http.Client{} + req, _ := http.NewRequest("DELETE", s.URL+"/0/id:1", nil) + res, _ := client.Do(req) + + expectedCode := 405 + compareAndShout(t, expectedCode, res.StatusCode) + + resultStr, _ := ioutil.ReadAll(res.Body) + res.Body.Close() + + var result types.ErrorType + json.Unmarshal([]byte(resultStr), &result) + + expectedError := "Method DELETE is not allowed" + compareAndShout(t, expectedError, result.Error) +} diff --git a/strings.go b/strings.go new file mode 100644 index 0000000..8382e5f --- /dev/null +++ b/strings.go @@ -0,0 +1,24 @@ +package main + +const strNotImplemented = "not implemented" +const strWrongTypeForIndexField = "wrong type for index/field" + +const strUsage = "Usage: ./rediseen [start/help/version]" +const strHeader = "rediseen " + rediseenVersion + +const strHelpDoc = strHeader + "\n\n" + + strUsage + "\n\n" + + "Configuration Items (via environment variables):\n" + + "- REDISEEN_PORT: port of the service. Default port is 8000\n" + + "- REDISEEN_REDIS_URI: URI of your Redis database, e.g. `redis://:@localhost:6379`\n" + + "- REDISEEN_KEY_PATTERN_EXPOSED: Regular expression pattern, " + + "representing the name pattern of keys that you intend to expose\n" + + "- REDISEEN_KEY_PATTERN_EXPOSE_ALL: If you intend to expose *all* your keys, " + + "set `REDISEEN_KEY_PATTERN_EXPOSE_ALL` to `true`" + +const strLogo = " _____ _ _ _____\n" + + "| __ \\ | |(_) / ____|\n" + + "| |__) | ___ __| | _ | (___ ___ ___ _ __ \n" + + "| _ / / _ \\ / _` || | \\___ \\ / _ \\ / _ \\| '_ \\\n" + + "| | \\ \\| __/| (_| || | ____) || __/| __/| | | |\n" + + "|_| \\_\\\\___| \\__,_||_||_____/ \\___| \\___||_| |_|" diff --git a/types/types.go b/types/types.go new file mode 100644 index 0000000..448e0ce --- /dev/null +++ b/types/types.go @@ -0,0 +1,15 @@ +package types + +type ResponseType struct { + ValueType string `json:"type"` + Value interface{} `json:"value"` +} + +type ErrorType struct { + Error string `json:"error"` +} + +type ResultType struct { + Action string `json:"action"` + Result string `json:"result"` +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..5b7bbf3 --- /dev/null +++ b/version.go @@ -0,0 +1,3 @@ +package main + +const rediseenVersion = "1.0.0-alpha"