From 9c0511afccdf22bd142951a753ecc2dace7658bc Mon Sep 17 00:00:00 2001 From: Matthew Rich Date: Thu, 4 Apr 2024 13:30:41 -0700 Subject: [PATCH] add gin service --- .gitea/workflows/lint.yaml | 21 ++ .gitea/workflows/release.yaml | 18 + .gitea/workflows/test.yaml | 54 +++ Makefile | 11 + README.md | 3 +- build_deps.yml | 5 + cmd/cli/main.go | 169 +++++++++ cmd/cli/main_test.go | 72 ++++ data/data.go | 9 + data/data_test.go | 28 ++ docs/docs.go | 318 +++++++++++++++++ docs/swagger.json | 294 ++++++++++++++++ docs/swagger.yaml | 194 +++++++++++ go.mod | 55 +++ go.sum | 161 +++++++++ go_deps.yml | 1 + httptest/stream/stream.go | 26 ++ httptest/stream/stream_test.go | 57 +++ internal/client/client.go | 47 +++ internal/client/client_test.go | 53 +++ internal/data/mock_redis_client_test.go | 42 +++ internal/data/redis.go | 61 ++++ internal/data/redis_test.go | 88 +++++ internal/models/models.go | 5 + internal/models/models_test.go | 9 + internal/models/resource.go | 26 ++ internal/models/resource_test.go | 17 + internal/models/tag.go | 25 ++ internal/models/tag_test.go | 16 + internal/service/data.go | 13 + internal/service/mock_data_connector_test.go | 36 ++ internal/service/service.go | 51 +++ internal/service/service_test.go | 95 +++++ main.go | 348 +++++++++++++++++++ main_test.go | 222 ++++++++++++ static/icons/tagger128-bl.png | Bin 0 -> 1744 bytes static/icons/tagger128-wh.png | Bin 0 -> 1950 bytes static/icons/tagger128.ico | Bin 0 -> 67646 bytes static/icons/tagger128.png | Bin 0 -> 2377 bytes static/icons/tagger16-mb.ico | Bin 0 -> 5355 bytes static/icons/tagger16-mw.ico | Bin 0 -> 1346 bytes static/icons/tagger16.ico | Bin 0 -> 1342 bytes static/icons/tagger256-bl.png | Bin 0 -> 3346 bytes static/icons/tagger256-wh.png | Bin 0 -> 3744 bytes static/icons/tagger32-bl.png | Bin 0 -> 803 bytes static/icons/tagger32-wh.png | Bin 0 -> 846 bytes static/icons/tagger32-whg.png | Bin 0 -> 954 bytes static/icons/tagger32.ico | Bin 0 -> 4286 bytes static/icons/tagger4.png | Bin 0 -> 1381 bytes static/icons/tagger48.ico | Bin 0 -> 9662 bytes static/icons/tagger64-bl.png | Bin 0 -> 1090 bytes static/icons/tagger64-wh.png | Bin 0 -> 1179 bytes static/icons/tagger64-whg.png | Bin 0 -> 1387 bytes static/icons/tagger64.ico | Bin 0 -> 16958 bytes static/icons/tagger96.ico | Bin 0 -> 38078 bytes templates/footer.tmpl | 2 + templates/header.tmpl | 17 + templates/index.tmpl | 39 +++ templates/query.tmpl | 18 + templates/tags.tmpl | 11 + tests/mocks/data_connector.go | 36 ++ tests/mocks/http_client.go | 16 + 62 files changed, 2788 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/lint.yaml create mode 100644 .gitea/workflows/release.yaml create mode 100644 .gitea/workflows/test.yaml create mode 100644 Makefile create mode 100644 build_deps.yml create mode 100644 cmd/cli/main.go create mode 100644 cmd/cli/main_test.go create mode 100644 data/data.go create mode 100644 data/data_test.go create mode 100644 docs/docs.go create mode 100644 docs/swagger.json create mode 100644 docs/swagger.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 go_deps.yml create mode 100644 httptest/stream/stream.go create mode 100644 httptest/stream/stream_test.go create mode 100644 internal/client/client.go create mode 100644 internal/client/client_test.go create mode 100644 internal/data/mock_redis_client_test.go create mode 100644 internal/data/redis.go create mode 100644 internal/data/redis_test.go create mode 100644 internal/models/models.go create mode 100644 internal/models/models_test.go create mode 100644 internal/models/resource.go create mode 100644 internal/models/resource_test.go create mode 100644 internal/models/tag.go create mode 100644 internal/models/tag_test.go create mode 100644 internal/service/data.go create mode 100644 internal/service/mock_data_connector_test.go create mode 100644 internal/service/service.go create mode 100644 internal/service/service_test.go create mode 100644 main.go create mode 100644 main_test.go create mode 100644 static/icons/tagger128-bl.png create mode 100644 static/icons/tagger128-wh.png create mode 100644 static/icons/tagger128.ico create mode 100644 static/icons/tagger128.png create mode 100644 static/icons/tagger16-mb.ico create mode 100644 static/icons/tagger16-mw.ico create mode 100644 static/icons/tagger16.ico create mode 100644 static/icons/tagger256-bl.png create mode 100644 static/icons/tagger256-wh.png create mode 100644 static/icons/tagger32-bl.png create mode 100644 static/icons/tagger32-wh.png create mode 100644 static/icons/tagger32-whg.png create mode 100644 static/icons/tagger32.ico create mode 100644 static/icons/tagger4.png create mode 100644 static/icons/tagger48.ico create mode 100644 static/icons/tagger64-bl.png create mode 100644 static/icons/tagger64-wh.png create mode 100644 static/icons/tagger64-whg.png create mode 100644 static/icons/tagger64.ico create mode 100644 static/icons/tagger96.ico create mode 100644 templates/footer.tmpl create mode 100644 templates/header.tmpl create mode 100644 templates/index.tmpl create mode 100644 templates/query.tmpl create mode 100644 templates/tags.tmpl create mode 100644 tests/mocks/data_connector.go create mode 100644 tests/mocks/http_client.go diff --git a/.gitea/workflows/lint.yaml b/.gitea/workflows/lint.yaml new file mode 100644 index 0000000..256b293 --- /dev/null +++ b/.gitea/workflows/lint.yaml @@ -0,0 +1,21 @@ +name: Lint +on: + - push +jobs: + lint: + name: golangci-lint + runs-on: ubuntu-latest + + steps: + - uses: actions/setup-go@v4 + with: + go-version: "1.21.1" + cache: false + + - name: Check out code + uses: actions/checkout@v3 + + - name: Lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.55 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..cbfc9b3 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,18 @@ +name: Releases + +on: + push: + tags: + - '*' + +jobs: + + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + - uses: ncipollo/release-action@v1 + with: + artifacts: "tagger" diff --git a/.gitea/workflows/test.yaml b/.gitea/workflows/test.yaml new file mode 100644 index 0000000..e54465f --- /dev/null +++ b/.gitea/workflows/test.yaml @@ -0,0 +1,54 @@ +name: Declarative Tests +run-name: ${{ gitea.actor }} testing +on: + - push + - workflow_dispatch + - issue_comment + +jobs: + test: + runs-on: ubuntu-latest + container: + image: registry.cv.mazarbul.net/declarative/build-golang:1.21.1-alpine-x86_64 + env: + GOPATH: / + SSH_AUTH_SOCK: /tmp/ssh.sock + ENVIRONMENT: dev + volumes: + - ${{ env.HOME }}/.ssh/known_hosts:/root/.ssh/known_hosts + - ${{ env.SSH_AUTH_SOCK }}:/tmp/ssh.sock + - /etc/gitconfig:/etc/gitconfig + - /etc/ssl/certs:/etc/ssl/certs + - ${{ vars.GITEA_WORKSPACE }}:/go/src + options: --cpus 1 + steps: + - run: apk add --no-cache nodejs + - name: Check out repository code + uses: actions/checkout@v3 + - run: echo "The ${{ gitea.repository }} repository has been cloned to the runner." + - run: echo "The workflow is now ready to test your code on the runner." + - name: Run tests + run: | + go test -coverprofile=artifacts/coverage.profile ./... + - name: Run build + run: | + make build + - name: cli tests + run: | + go test + - run: echo "This job's status is ${{ job.status }}." + - run: echo "This job's status is ${{ job.status }}." + - name: coverage report + run: | + go tool cover -html=artifacts/coverage.profile -o artifacts/code-coverage.html + - name: Archive code coverage results + uses: actions/upload-artifact@v3 + with: + name: code-coverage-report + path: artifacts/code-coverage.html + - name: Archive binary + uses: actions/upload-artifact@v3 + with: + name: tagger + path: tagger + - run: echo "This job's status is ${{ job.status }}." diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..13d3cfb --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +LDFLAGS?=--ldflags '-extldflags "-static"' +export CGO_ENABLED=0 + +build: tagger + +tagger: + go build -o tagger $(LDFLAGS) +tagger-cli: + go build -o tagger-cli $(LDFLAGS) cmd/cli/main.go +test: + go test ./... diff --git a/README.md b/README.md index 3b9714d..e15b68b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # tagger +Tagging service -REST API for creating resource tags \ No newline at end of file +Provides a REST API and swagger docs for tagging resources. diff --git a/build_deps.yml b/build_deps.yml new file mode 100644 index 0000000..1d3eab4 --- /dev/null +++ b/build_deps.yml @@ -0,0 +1,5 @@ +- make +- libc-dev +- git +- openssh-client +- gcc diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..69029f2 --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "io" +_ "io/ioutil" + "fmt" + "strings" + "regexp" + "tagger/internal/models" + "tagger/internal/client" + "flag" + "net/http" + "golang.org/x/net/html" + "container/list" +_ "unicode" + "net/url" +) + +type htmlNode html.Node + +func metaKeywords(n *htmlNode) []string { + var name, content string + re := regexp.MustCompile(`[^-,\w\s]+`) + fmt.Printf("meta ") + for _, attribute := range n.Attr { + fmt.Printf(" %s=%s", attribute.Key, attribute.Val) + if attribute.Key == "name" { + if attribute.Val != "keywords" && attribute.Val != "title" && attribute.Val != "description" { + fmt.Printf("\n") + return []string{} + } else { + name = attribute.Val + } + } + if attribute.Key == "content" { + content,_ = url.PathUnescape(attribute.Val) + } + } + fmt.Printf("\n") + if name != "" { + var terms []string + if name == "keywords" { + terms = strings.Split(re.ReplaceAllString(content, ""), ",") + } else { + terms = []string{strings.ReplaceAll(re.ReplaceAllString(content, ""), ",", "")} + } + for i,t := range(terms) { + terms[i] = strings.ToLower(strings.ReplaceAll(strings.TrimSpace(t), " ", "-")) + } + return terms + //return strings.FieldsFunc(content, func(r rune) bool { return ! ( unicode.IsLetter(r) || unicode.IsNumber(r) || r == '-' ) }) + } + return []string{} +} + + +func (h *htmlNode) FindAll(tagName string) []*htmlNode { + if h == nil { + return nil + } + return h.FindNodes(tagName, false) +} + +func (h *htmlNode) Find(tagName string) *htmlNode { + if h == nil { + return nil + } + results := h.FindNodes(tagName, true) + if len(results) > 0 { + return results[0] + } + return nil +} + +func (h *htmlNode) FindNodes(tagName string, first bool) []*htmlNode { + if h == nil { + return nil + } + + n := (*html.Node)(h) + q := list.New() + + var results []*htmlNode + + q.PushBack(n) + + for q.Len() > 0 { + v := (*html.Node)(q.Remove(q.Front()).(*html.Node)) + if v.Type == html.ElementNode && v.Data == tagName { + results = append(results, (*htmlNode)(v)) + if first { + break + } + } + + for c := v.FirstChild; c != nil; c = c.NextSibling { + q.PushBack(c) + } + } + return results +} + +func extractTagsMetaDataFromUrl(resource io.ReadCloser) ([]string, error) { + var results []string + doc,e := html.Parse(resource) + if e != nil { + panic(e) + } + + for _,v := range (*htmlNode)(doc).Find("head").FindAll("meta") { + results = append(results, metaKeywords(v)...) + } + return results, nil +} + +func main() { + + tag := flag.String("tag", "tag-name", "Tag name") + //resource := flag.String("resource", "resource URL", "Resource URL") + + endpoint := flag.String("endpoint", "http://localhost:8080/api/v1", "API endpoint URL") + + extractUrl := flag.String("extract", "", "Extract tags from resource URL") + + flag.Parse() + + fmt.Printf("%#v\n", extractUrl) + + + cli := client.New(*endpoint) + + if e := cli.Ping(); e != nil { + panic(e) + } + + if len(*extractUrl) > 0 { + s,e := http.Get(*extractUrl) + if e != nil { + panic(e) + } + terms, e := extractTagsMetaDataFromUrl(s.Body) + // filter terms + + fmt.Printf("%s\n", terms) + + for _,t := range(terms) { + cli.AddTagResource(t, *extractUrl) + } + + return + } + + requestUrl := fmt.Sprintf("%s/tags/%s", *endpoint, *tag) + r,e := http.Get(requestUrl) + + if e != nil { + panic(e) + } + + defer r.Body.Close() + + tagModel, e := models.NewTagFromJson(r.Body) + + if e != nil { + //http.Error(w, err.Error(), http.StatusBadRequest) + } + + fmt.Printf("%#v\n", tagModel) +} diff --git a/cmd/cli/main_test.go b/cmd/cli/main_test.go new file mode 100644 index 0000000..c7ca49d --- /dev/null +++ b/cmd/cli/main_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "io" + "fmt" + "strings" + "testing" + "github.com/stretchr/testify/assert" + "golang.org/x/net/html" +) + +/* + + + + + + + + + + + + + + + + + + + + + + + +*/ +func TestExtractUrl(t *testing.T) { + htmlData := ` + + + + + +` + + reader := io.NopCloser(strings.NewReader(htmlData)) + + meta, _ := extractTagsMetaDataFromUrl(reader) + assert.Greater(t, len(meta), 0) + assert.Contains(t, meta, "international") + assert.Contains(t, meta, "401k") +} + +func TestHtmlFile(t *testing.T) { + htmlData := ` + + + + + +` + + reader := io.NopCloser(strings.NewReader(htmlData)) + doc,_ := html.Parse(reader) + + v := (*htmlNode)(doc).Find("head").FindAll("meta") + + for i := 0; i < len(v); i++ { + fmt.Printf("%#v\n", v[i].Data) + } + assert.Equal(t, 4, len(v)) +} diff --git a/data/data.go b/data/data.go new file mode 100644 index 0000000..e1c2b09 --- /dev/null +++ b/data/data.go @@ -0,0 +1,9 @@ +package data + +type Reader interface { + Read() +} + +type Connector interface { + Connect() DataSource +} diff --git a/data/data_test.go b/data/data_test.go new file mode 100644 index 0000000..6c13d6f --- /dev/null +++ b/data/data_test.go @@ -0,0 +1,28 @@ +package data + +import ( + "testing" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" +) + +func TestNewDataSource(t *testing.T) { + ds := NewSource() + if ds == nil { + t.Errorf("Failed creating new data source") + } +} + +func TestDataSourceOpen(t *testing.T) { + ds := NewSource() + assert.Equal(t, ds.Open(), nil) +} + +type MockConnection struct {} + +func (m *MockConnection) LRange(context.Context, string, int64, int64) (*redis.StringSliceCmd) { return } + +func TestDataSourceGet(t *testing.T) { + ds := NewSource() + ds.Use(&MockConnection{}) +} diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..5727819 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,318 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "matthewrich.conf@gmail.com" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/ping": { + "get": { + "description": "ping alive endpoint", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ping" + ], + "summary": "ping endpoint", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, + "/resources": { + "get": { + "description": "Get resource to tags", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Get resource tags", + "parameters": [ + { + "type": "string", + "description": "Resource URL", + "name": "resource", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Resource" + } + } + } + } + }, + "/resources/{resource}": { + "get": { + "description": "Get resource to tags", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Get resource tags", + "parameters": [ + { + "type": "string", + "description": "Base64 encoded Resource URL", + "name": "resource", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Resource" + } + } + } + } + }, + "/tags": { + "post": { + "description": "Add a tag for a resource", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Add a tag for a resource", + "parameters": [ + { + "description": "Tag JSON object", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.tag" + } + } + } + } + }, + "/tags/{name}": { + "get": { + "description": "Get resources associated with a Tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Get list of resources tagged", + "parameters": [ + { + "type": "string", + "description": "Tag name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "put": { + "description": "Updates the resources associated with a tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Update a tag", + "parameters": [ + { + "type": "string", + "description": "Tag name", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Tag JSON object", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.tag" + } + } + } + } + }, + "/tags/{tag}": { + "post": { + "description": "Add a resource to a tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Add a resource to a tag", + "parameters": [ + { + "type": "string", + "description": "Tag name", + "name": "tag", + "in": "path", + "required": true + }, + { + "description": "Resource JSON object", + "name": "resource", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.tag" + } + } + } + } + } + }, + "definitions": { + "main.tag": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "models.Resource": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "0.31", + Host: "localhost:8080", + BasePath: "/api/v1", + Schemes: []string{}, + Title: "Tagger API", + Description: "This is the Tagger API.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..c30df9f --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,294 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is the Tagger API.", + "title": "Tagger API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "http://www.swagger.io/support", + "email": "matthewrich.conf@gmail.com" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "0.31" + }, + "host": "localhost:8080", + "basePath": "/api/v1", + "paths": { + "/ping": { + "get": { + "description": "ping alive endpoint", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "ping" + ], + "summary": "ping endpoint", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "string" + } + } + } + } + }, + "/resources": { + "get": { + "description": "Get resource to tags", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Get resource tags", + "parameters": [ + { + "type": "string", + "description": "Resource URL", + "name": "resource", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Resource" + } + } + } + } + }, + "/resources/{resource}": { + "get": { + "description": "Get resource to tags", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Get resource tags", + "parameters": [ + { + "type": "string", + "description": "Base64 encoded Resource URL", + "name": "resource", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Resource" + } + } + } + } + }, + "/tags": { + "post": { + "description": "Add a tag for a resource", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Add a tag for a resource", + "parameters": [ + { + "description": "Tag JSON object", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.tag" + } + } + } + } + }, + "/tags/{name}": { + "get": { + "description": "Get resources associated with a Tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Tags" + ], + "summary": "Get list of resources tagged", + "parameters": [ + { + "type": "string", + "description": "Tag name", + "name": "name", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "put": { + "description": "Updates the resources associated with a tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Update a tag", + "parameters": [ + { + "type": "string", + "description": "Tag name", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Tag JSON object", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.tag" + } + } + } + } + }, + "/tags/{tag}": { + "post": { + "description": "Add a resource to a tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag resource" + ], + "summary": "Add a resource to a tag", + "parameters": [ + { + "type": "string", + "description": "Tag name", + "name": "tag", + "in": "path", + "required": true + }, + { + "description": "Resource JSON object", + "name": "resource", + "in": "body", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/main.tag" + } + } + } + } + } + }, + "definitions": { + "main.tag": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "models.Resource": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..c7a5783 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,194 @@ +basePath: /api/v1 +definitions: + main.tag: + properties: + id: + type: string + name: + type: string + resources: + items: + type: string + type: array + type: object + models.Resource: + properties: + id: + type: string + resource: + type: string + tags: + items: + type: string + type: array + type: object +host: localhost:8080 +info: + contact: + email: matthewrich.conf@gmail.com + name: API Support + url: http://www.swagger.io/support + description: This is the Tagger API. + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + termsOfService: http://swagger.io/terms/ + title: Tagger API + version: "0.31" +paths: + /ping: + get: + consumes: + - application/json + description: ping alive endpoint + produces: + - application/json + responses: + "200": + description: OK + schema: + type: string + summary: ping endpoint + tags: + - ping + /resources: + get: + consumes: + - application/json + description: Get resource to tags + parameters: + - description: Resource URL + in: query + name: resource + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Resource' + summary: Get resource tags + tags: + - tag resource + /resources/{resource}: + get: + consumes: + - application/json + description: Get resource to tags + parameters: + - description: Base64 encoded Resource URL + in: path + name: resource + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Resource' + summary: Get resource tags + tags: + - tag resource + /tags: + post: + consumes: + - application/json + description: Add a tag for a resource + parameters: + - description: Tag JSON object + in: body + name: tag + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/main.tag' + summary: Add a tag for a resource + tags: + - tag resource + /tags/{name}: + get: + consumes: + - application/json + description: Get resources associated with a Tag + parameters: + - description: Tag name + in: path + name: name + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + type: string + type: array + summary: Get list of resources tagged + tags: + - Tags + put: + consumes: + - application/json + description: Updates the resources associated with a tag + parameters: + - description: Tag name + in: path + name: name + required: true + type: string + - description: Tag JSON object + in: body + name: tag + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/main.tag' + summary: Update a tag + tags: + - tag resource + /tags/{tag}: + post: + consumes: + - application/json + description: Add a resource to a tag + parameters: + - description: Tag name + in: path + name: tag + required: true + type: string + - description: Resource JSON object + in: body + name: resource + required: true + schema: + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/main.tag' + summary: Add a resource to a tag + tags: + - tag resource +swagger: "2.0" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f20b10c --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module tagger + +go 1.21.1 + +require ( + github.com/gin-contrib/sse v0.1.0 + github.com/gin-gonic/gin v1.9.1 + github.com/redis/go-redis/v9 v9.4.0 + github.com/stretchr/testify v1.8.4 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.0 + github.com/swaggo/swag v1.16.3 + github.com/twmb/murmur3 v1.1.8 + golang.org/x/net v0.21.0 + google.golang.org/protobuf v1.32.0 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/bytedance/sonic v1.10.2 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect + github.com/chenzhuoyu/iasm v0.9.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/cors v1.5.0 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect + github.com/go-openapi/jsonreference v0.20.4 // indirect + github.com/go-openapi/spec v0.20.14 // indirect + github.com/go-openapi/swag v0.22.9 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.18.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/go-cmp v0.5.9 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.6 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.7.0 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.18.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e9c6ee3 --- /dev/null +++ b/go.sum @@ -0,0 +1,161 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= +github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE= +github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= +github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0= +github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk= +github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= +github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= +github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= +github.com/go-openapi/spec v0.20.14 h1:7CBlRnw+mtjFGlPDRZmAMnq35cRzI91xj03HVyUi/Do= +github.com/go-openapi/spec v0.20.14/go.mod h1:8EOhTpBoFiask8rrgwbLC3zmJfz4zsCUueRuPM6GNkw= +github.com/go-openapi/swag v0.22.9 h1:XX2DssF+mQKM2DHsbgZK74y/zj4mo9I99+89xUmuZCE= +github.com/go-openapi/swag v0.22.9/go.mod h1:3/OXnFfnMAwBD099SwYRk7GD3xOrr1iL7d/XNLXVVwE= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.18.0 h1:BvolUXjp4zuvkZ5YN5t7ebzbhlUtPsPm2S9NAZ5nl9U= +github.com/go-playground/validator/v10 v10.18.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc= +github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= +github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk= +github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= +github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= +github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= +golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= +golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/go_deps.yml b/go_deps.yml new file mode 100644 index 0000000..ce5e602 --- /dev/null +++ b/go_deps.yml @@ -0,0 +1 @@ +- github.com/swaggo/swag/cmd/swag@latest diff --git a/httptest/stream/stream.go b/httptest/stream/stream.go new file mode 100644 index 0000000..127e40f --- /dev/null +++ b/httptest/stream/stream.go @@ -0,0 +1,26 @@ + +package stream + +import ( + "net/http/httptest" +) + +type Recorder struct { + *httptest.ResponseRecorder + closeStream chan bool +} + +func (r *Recorder) CloseNotify() <-chan bool { + return r.closeStream +} + +func (r *Recorder) Close() { + r.closeStream <- true +} + +func NewRecorder() *Recorder{ + return &Recorder{ + httptest.NewRecorder(), + make(chan bool), + } +} diff --git a/httptest/stream/stream_test.go b/httptest/stream/stream_test.go new file mode 100644 index 0000000..e741f8f --- /dev/null +++ b/httptest/stream/stream_test.go @@ -0,0 +1,57 @@ +package stream + +import ( +_ "fmt" + "io" + "testing" + "net/http" + "net/http/httptest" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +_ "encoding/json" +_ "strings" +) + +func TestStreamRecorder(t *testing.T) { + r := NewRecorder() + if r == nil { + t.Errorf("Failed creating new recorder") + } + if r.CloseNotify() == nil { + t.Errorf("Failed creating CloseNotify channel") + } +} + +func TestStreamClose(t *testing.T) { + r := NewRecorder() + go r.Close() + + select { + case flag, open := <-r.CloseNotify(): + if flag && open { + return + } + } + t.Errorf("Failed to close the stream close channel") +} + +func TestStream(t *testing.T) { + r := NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/test", nil) + + g := gin.New() + g.Handle(http.MethodGet, "/test", func(c *gin.Context){ + c.Stream(func(w io.Writer) bool { c.SSEvent("message", "msg"); return false }) + <-c.Writer.CloseNotify() + }) + + go g.ServeHTTP(r, request) + + + + for !r.Flushed {} + + r.Close() + assert.Equal(t, r.Body.String(), "event:message\ndata:msg\n\n") +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 0000000..a9ed049 --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,47 @@ +package client + +import ( +_ "io" + "fmt" + "strings" +_ "encoding/json" + "net/http" +) + +type TaggerApi struct { + client *http.Client + endpoint string +} + +func New(endpointUrl string) *TaggerApi { + return &TaggerApi{ + client: &http.Client{}, + endpoint: endpointUrl, + } +} + +func (t *TaggerApi) assertReceiver() { + if t == nil { + panic("Method can't be called on nil receiver") + } +} + +func (t *TaggerApi) Ping() error { + t.assertReceiver() + resp, e := t.client.Get(fmt.Sprintf("%s/ping", t.endpoint)) + defer resp.Body.Close() + + return e +} + +func (t *TaggerApi) AddTagResource(tagName string, resourceUrl string) error { + t.assertReceiver() + + jsonReader := strings.NewReader(fmt.Sprintf(`{ "resource": "%s" }`, resourceUrl)) + + resp, e := t.client.Post(fmt.Sprintf("%s/tags/%s", t.endpoint, tagName), "application/json", jsonReader) + if e == nil { + defer resp.Body.Close() + } + return e +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..36d38f3 --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,53 @@ +package client + +import ( + "fmt" + "testing" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" +_ "strings" +) + +func TestNewClient(t *testing.T) { + cli := New("http://localhost:8080") + assert.NotEqual(t, nil, cli) +} + +func TestClientConnection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, req.URL.String(), "/api/v1/ping") + rw.Write([]byte(`pong`)) + })) + + defer server.Close() + + cli := New(fmt.Sprintf("%s/api/v1", server.URL)) + e := cli.Ping() + assert.Equal(t, nil, e) +} + +/* +func TestClientDecodeJsonResponse(t *testing.T) { + cli := New() + statusJsonDocument := strings.NewReader(`{"status": "success"}`) + + v := cli.decodeJsonResponse(statusJsonDocument) + assert.Greater(t, 0, len(v)) +} +*/ + +func TestClientAddTagResource(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, req.URL.String(), "/api/v1/tags/finance") + rw.Write([]byte(`{ "status": "success" }`)) + })) + + defer server.Close() + + + cli := New(fmt.Sprintf("%s/api/v1", server.URL)) + + err := cli.AddTagResource("finance", "https://finance.yahoo.com") + assert.Equal(t, nil, err) +} diff --git a/internal/data/mock_redis_client_test.go b/internal/data/mock_redis_client_test.go new file mode 100644 index 0000000..bc02310 --- /dev/null +++ b/internal/data/mock_redis_client_test.go @@ -0,0 +1,42 @@ +package data + +import ( + "context" + "github.com/redis/go-redis/v9" +) + +type MockRedisClient struct { + InjectPing func(ctx context.Context) *redis.StatusCmd + InjectLRange func(ctx context.Context, key string, start, stop int64) *redis.StringSliceCmd + InjectSInter func(ctx context.Context, keys ...string) *redis.StringSliceCmd + InjectKeys func(ctx context.Context, pattern string) *redis.StringSliceCmd + InjectSAdd func(ctx context.Context, key string, members ...interface{}) *redis.IntCmd + InjectSIsMember func(ctx context.Context, key string, member interface{}) *redis.BoolCmd +} + +func (m *MockRedisClient) Ping(ctx context.Context) *redis.StatusCmd { + if m.InjectPing == nil { + return redis.NewStatusCmd(ctx, "ping") + } + return m.InjectPing(ctx) +} + +func (m *MockRedisClient) LRange(ctx context.Context, key string, start, stop int64) *redis.StringSliceCmd { + return m.InjectLRange(ctx, key, start, stop) +} + +func (m *MockRedisClient) SInter(ctx context.Context, keys ...string) *redis.StringSliceCmd { + return m.InjectSInter(ctx, keys...) +} + +func (m *MockRedisClient) Keys(ctx context.Context, pattern string) *redis.StringSliceCmd { + return m.InjectKeys(ctx, pattern) +} + +func (m *MockRedisClient) SAdd(ctx context.Context, key string, members ...interface{}) *redis.IntCmd { + return m.InjectSAdd(ctx, key, members...) +} + +func (m *MockRedisClient) SIsMember(ctx context.Context, key string, member interface{}) *redis.BoolCmd { + return m.InjectSIsMember(ctx, key, member) +} diff --git a/internal/data/redis.go b/internal/data/redis.go new file mode 100644 index 0000000..b7fe12e --- /dev/null +++ b/internal/data/redis.go @@ -0,0 +1,61 @@ +package data + +import ( + "context" + "github.com/redis/go-redis/v9" +) + +type RedisClientConnector interface { + Ping(ctx context.Context) *redis.StatusCmd + LRange(ctx context.Context, key string, start, stop int64) *redis.StringSliceCmd + SInter(ctx context.Context, keys ...string) *redis.StringSliceCmd + Keys(ctx context.Context, pattern string) *redis.StringSliceCmd + SAdd(ctx context.Context, key string, members ...interface{}) *redis.IntCmd + SIsMember(ctx context.Context, key string, member interface{}) *redis.BoolCmd +} + +type Redis struct { + client RedisClientConnector +} + +func NewRedisConnector(client RedisClientConnector) *Redis { + if client == nil { + client = redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", // no password set + DB: 0, // use default DB + }) + } + return &Redis{ client: client } +} + +func (r *Redis) Tags(context context.Context) []string { + result,_ := r.client.Keys(context, "*").Result() + return result +} + +func (r *Redis) Connected(context context.Context) bool { + if e := r.client.Ping(context).Err(); e != nil { + return false + } + return true +} + +func (r *Redis) Query(context context.Context, terms []string) ([]string, error) { + result,_ := r.client.SInter(context, terms...).Result() + return result, nil +} + +func (r *Redis) AddTag(context context.Context, key string, resource string) error { + if r,e := r.client.SAdd(context, key, resource).Result(); r != 1 || e != nil { + return e + } + return nil +} + +func (r *Redis) ResourceHasTag(context context.Context, resource string, tag string) bool { + if r,e := r.client.SIsMember(context, tag, resource).Result(); r == false || e != nil { + return false + } + return true +} diff --git a/internal/data/redis_test.go b/internal/data/redis_test.go new file mode 100644 index 0000000..b42a3b3 --- /dev/null +++ b/internal/data/redis_test.go @@ -0,0 +1,88 @@ +package data + +import ( + "testing" + "context" + "github.com/stretchr/testify/assert" + "github.com/redis/go-redis/v9" +) + +func TestNewRedisConnector(t *testing.T) { + r := NewRedisConnector(nil) + assert.NotEqual(t, r, nil) +} + +func TestRedisConnectorConnected(t *testing.T) { + m := &MockRedisClient{} + r := NewRedisConnector(m) + + assert.Equal(t, r.Connected(context.Background()), true) +} + +func TestRedisConnectorQuery(t *testing.T) { + expected := []string { "https://www.google.com" } + + m := &MockRedisClient{ + InjectSInter: func(ctx context.Context, keys ...string) *redis.StringSliceCmd { + return redis.NewStringSliceResult(expected, nil) + }, + } + r := NewRedisConnector(m) + + results, e := r.Query(context.Background(), []string { "search-engine", "ai" }) + + assert.Equal(t, e, nil) + assert.Equal(t, len(expected), len(results)) + + for i,e := range expected { + assert.Equal(t, results[i], e) + } +} + +func TestRedisConnectorTags(t *testing.T) { + expected := []string { "search-engine", "ai" } + + m := &MockRedisClient { + InjectKeys: func(ctx context.Context, pattern string) *redis.StringSliceCmd { + return redis.NewStringSliceResult(expected, nil) + }, + } + + r := NewRedisConnector(m) + + results := r.Tags(context.Background()) + + assert.Equal(t, len(expected), len(results)) + + for i,e := range expected { + assert.Equal(t, results[i], e) + } +} + +func TestRedisConnectorAddTag(t *testing.T) { + var expected int64 = 1 + m := &MockRedisClient { + InjectSAdd: func(ctx context.Context, key string, members ...interface{}) *redis.IntCmd { + return redis.NewIntResult(expected, nil) + }, + } + + r := NewRedisConnector(m) + + e := r.AddTag(context.Background(), "search-engine", "https://www.yahoo.com") + + assert.Equal(t, e, nil) + +} + +func TestRedisConnectorResourceHasTag(t *testing.T) { + m := &MockRedisClient { + InjectSIsMember: func(ctx context.Context, key string, member interface{}) *redis.BoolCmd { + return redis.NewBoolResult(true, nil) + }, + } + + r := NewRedisConnector(m) + + assert.Equal(t, r.ResourceHasTag(context.Background(), "https://www.yahoo.com", "search-engine"), true) +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..5a2717a --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,5 @@ +package models + +import ( + +) diff --git a/internal/models/models_test.go b/internal/models/models_test.go new file mode 100644 index 0000000..e8dd6ee --- /dev/null +++ b/internal/models/models_test.go @@ -0,0 +1,9 @@ +package models + +import ( + "testing" +) + +func TestNewModel(t *testing.T) { + +} diff --git a/internal/models/resource.go b/internal/models/resource.go new file mode 100644 index 0000000..f266a92 --- /dev/null +++ b/internal/models/resource.go @@ -0,0 +1,26 @@ +package models + +import ( + "io" + "encoding/json" +_ "fmt" +) + +type Resource struct { + Id string `json:"id",omitempty` + Resource string `json:"resource",omitempty` + Tags []string `json:"tags"` +} + +func NewResource() *Resource { + return &Resource{} +} + +func NewResourceFromJson(jsonReader io.Reader) (*Resource, error) { + r := NewResource() + err := json.NewDecoder(jsonReader).Decode(r) + if err != nil { + return nil, err + } + return r, nil +} diff --git a/internal/models/resource_test.go b/internal/models/resource_test.go new file mode 100644 index 0000000..30745d8 --- /dev/null +++ b/internal/models/resource_test.go @@ -0,0 +1,17 @@ +package models + +import ( + "io" + "strings" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewResourceFromJson(t *testing.T) { + testJsonDocument := strings.NewReader(`{ "resource": "bar", "tags": [ "foo" ] }`) + + r,e := NewResourceFromJson(io.NopCloser(testJsonDocument)) + assert.Equal(t, e, nil) + assert.Equal(t, r.Resource, "bar") + assert.Contains(t, r.Tags, "foo") +} diff --git a/internal/models/tag.go b/internal/models/tag.go new file mode 100644 index 0000000..133a8c3 --- /dev/null +++ b/internal/models/tag.go @@ -0,0 +1,25 @@ +package models + +import ( + "io" + "encoding/json" +) + +type Tag struct { + Id string `json:"id",omitempty` + Name string `json:"name",omitempty` + Resources []string `json:"resources"` +} + +func NewTag() *Tag { + return &Tag{} +} + +func NewTagFromJson(jsonReader io.ReadCloser) (*Tag, error) { + t := NewTag() + err := json.NewDecoder(jsonReader).Decode(t) + if err != nil { + return nil, err + } + return t, nil +} diff --git a/internal/models/tag_test.go b/internal/models/tag_test.go new file mode 100644 index 0000000..d74d4d4 --- /dev/null +++ b/internal/models/tag_test.go @@ -0,0 +1,16 @@ +package models + +import ( + "io" + "strings" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestNewTagFromJson(t *testing.T) { + testJsonDocument := strings.NewReader(`{ "name": "foo", "resources": [ "bar" ] }`) + + tag,e := NewTagFromJson(io.NopCloser(testJsonDocument)) + assert.Equal(t, e, nil) + assert.Equal(t, tag.Name, "foo") +} diff --git a/internal/service/data.go b/internal/service/data.go new file mode 100644 index 0000000..ff4458f --- /dev/null +++ b/internal/service/data.go @@ -0,0 +1,13 @@ +package service + +import ( + "context" +) + +type DataConnector interface { + Connected(ctx context.Context) bool + Query(ctx context.Context, terms []string) ([]string, error) + Tags(ctx context.Context) []string + AddTag(ctx context.Context, TagName string, Resource string) error + ResourceHasTag(ctx context.Context, Resource string, TagName string) bool +} diff --git a/internal/service/mock_data_connector_test.go b/internal/service/mock_data_connector_test.go new file mode 100644 index 0000000..03e2c25 --- /dev/null +++ b/internal/service/mock_data_connector_test.go @@ -0,0 +1,36 @@ +package service + +import ( + "context" +) + +type MockDataConnector struct { + InjectConnected func(context.Context) bool + InjectQuery func(context.Context, []string) ([]string, error) + InjectTags func(context.Context) []string + InjectAddTag func(context.Context, string, string) error + InjectResourceHasTag func(context.Context, string, string) bool +} + +func (m *MockDataConnector) Connected(ctx context.Context) bool { + if m.InjectConnected == nil { + return true + } + return m.InjectConnected(ctx) +} + +func (m *MockDataConnector) Query(ctx context.Context, terms []string) ([]string, error) { + return m.InjectQuery(ctx, terms) +} + +func (m *MockDataConnector) Tags(ctx context.Context) []string { + return m.InjectTags(ctx) +} + +func (m *MockDataConnector) AddTag(ctx context.Context, TagName string, Resource string) error { + return m.InjectAddTag(ctx, TagName, Resource) +} + +func (m *MockDataConnector) ResourceHasTag(ctx context.Context, TagName string, Resource string) bool { + return m.InjectResourceHasTag(ctx, TagName, Resource) +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..4d16159 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,51 @@ +package service + +import ( + "errors" +_ "io" + "context" +_ "fmt" +_ "net/http" +_ "encoding/json" +_ "github.com/gin-gonic/gin" +) + +type Service struct { + connector DataConnector +} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) Tags(ctx context.Context) []string { + return s.connector.Tags(ctx) +} + +func (s *Service) Query(ctx context.Context, terms []string) []string { + result, _ := s.connector.Query(ctx, terms) + return result +} + +func (s *Service) UseDataConnector(ctx context.Context, d DataConnector) error { + if d != nil && d.Connected(ctx) { + s.connector = d + return nil + } + return errors.New("The connector is not connected") +} + +func (s *Service) AddTag(ctx context.Context, TagName string, Resource string) error { + return s.connector.AddTag(ctx, TagName, Resource) +} + +func (s *Service) ResourceTags(ctx context.Context, Resource string) []string { + tags := s.connector.Tags(ctx) + results := make([]string, 0, len(tags)/10 ) + for _,t := range(s.connector.Tags(ctx)) { + if(s.connector.ResourceHasTag(ctx, Resource, t)) { + results = append(results, t) + } + } + return results +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..033e66d --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,95 @@ +package service + +import ( + "testing" + "context" + "github.com/stretchr/testify/assert" +) + +func TestNewService(t *testing.T) { + assert.NotEqual(t, NewService(), nil) +} + +func TestServiceTags(t *testing.T) { + s := NewService() + + expected := []string { "search-engines", "ai", "been-there" } + ctx := context.Background() + m := &MockDataConnector { + InjectTags: func(ctx context.Context) []string { return expected }, + } + assert.Equal(t, s.UseDataConnector(ctx, m), nil) + + results := s.Tags(ctx) + assert.Equal(t, len(results), len(expected)) + for i,r := range results { + assert.Equal(t, r, expected[i]) + } +} + +func TestServiceTagQuery(t *testing.T) { + s := NewService() + + expected := []string { + "https://www.google.com", + "https://www.bing.com", + } + ctx := context.Background() + m := &MockDataConnector { + InjectQuery: func(ctx context.Context, terms []string) ([]string, error) { return expected, nil }, + } + assert.Equal(t, s.UseDataConnector(ctx, m), nil) + + results := s.Query(ctx, []string{"search-engine", "ai"}) + assert.Equal(t, len(results), len(expected)) + for i,r := range results { + assert.Equal(t, r, expected[i]) + } +} + +func TestUseDataConnector(t *testing.T) { + s := NewService() + m := &MockDataConnector {} + + assert.Equal(t, s.UseDataConnector(context.Background(), m), nil) +} + +func TestServiceAddTag(t *testing.T) { + ctx := context.Background() + s := NewService() + m := &MockDataConnector { + InjectAddTag: func(ctx context.Context, TagName string, Resource string) error { return nil }, + } + assert.Equal(t, s.UseDataConnector(ctx, m), nil) + + s.AddTag(ctx, "search-engine", "https://www.yahoo.com") + +} + +/* +func TestServiceExtractTags(t *testing.T) { + ctx := context.Background() + s := NewService() + m := &MockDataConnector { + + } + assert.Equal(t, s. +} +*/ + +func TestServiceTagsByResource(t *testing.T) { + tags := []string{ "search-engine", "yahoo-search" } + ctx := context.Background() + s := NewService() + + m := &MockDataConnector { + InjectTags: func(ctx context.Context) []string { return tags }, + InjectResourceHasTag: func(ctx context.Context, TagName string, Resource string) bool { return true }, + } + + assert.Equal(t, s.UseDataConnector(ctx, m), nil) + v := s.ResourceTags(ctx, "https://www.yahoo.com") + // search through all key sets and check if the resource exists (at best O(N) for all possible tags) + // or just store the set of tags for each resource + assert.Contains(t, v, "search-engine") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..d582622 --- /dev/null +++ b/main.go @@ -0,0 +1,348 @@ +package main + +import ( + "io" + "context" +_ "fmt" +_ "log" + "strings" + "net/http" +_ "encoding/json" + "github.com/gin-gonic/gin" + "github.com/gin-contrib/cors" + "github.com/redis/go-redis/v9" + "tagger/internal/data" + "tagger/internal/models" + "tagger/internal/service" + docs "tagger/docs" + swaggerfiles "github.com/swaggo/files" + ginswagger "github.com/swaggo/gin-swagger" + "encoding/base64" +) + +// @title Tagger API +// @version 0.31 +// @description This is the Tagger API. +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url http://www.swagger.io/support +// @contact.email matthewrich.conf@gmail.com + +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html + +// @host localhost:8080 +// @BasePath /api/v1 + +type App struct { + apiBasePath string + service *service.Service + router *gin.Engine + connector service.DataConnector +} + +type tag struct { + Id string `json:"id",omitempty` + Name string `json:"name",omitempty` + Resources []string `json:"resources"` +} + +type query struct { + Terms string `form:"terms[]"` +} + +// @BasePath /api/v1 + +// Ping godoc +// @Summary ping endpoint +// @Schemes +// @Description ping alive endpoint +// @Tags ping +// @Accept json +// @Produce json +// @Success 200 {string} pong +// @Router /ping [get] +func (a *App) Ping(c *gin.Context) { + c.String(200, "pong") +} + +func SSEHeadersMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if c.GetHeader("Content-Type") == "text/event-stream" { + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + } + c.Next() + } +} + +// @BasePath /api/v1 + +// Tag godoc +// @Summary Update a tag +// @Schemes +// @Description Updates the resources associated with a tag +// @Tags tag resource +// @Accept json +// @Produce json +// @Success 200 {object} tag +// @Param name path string true "Tag name" +// @Param tag body string true "Tag JSON object" +// @Router /tags/{name} [put] +func (a *App) updateTag(c *gin.Context) { + var t tag + tagName := c.Param("tag") + + if err := c.ShouldBindJSON(&t); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + for _,resource := range(t.Resources) { + a.service.AddTag(c, tagName, resource) + } + + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +// @BasePath /api/v1 + +// Tag godoc +// @Summary Add a tag for a resource +// @Schemes +// @Description Add a tag for a resource +// @Tags tag resource +// @Accept json +// @Produce json +// @Success 200 {object} tag +// @Param tag body string true "Tag JSON object" +// @Router /tags [post] +func (a *App) createTag(c *gin.Context) { + var t tag + if err := c.ShouldBindJSON(&t); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + for _,resource := range(t.Resources) { + a.service.AddTag(c, t.Name, resource) + } + + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +// @BasePath /api/v1 + +// Tags godoc +// @Summary Get list of resources tagged +// @Schemes +// @Description Get resources associated with a Tag +// @Tags Tags +// @Accept json +// @Produce json +// @Success 200 {array} string +// @Param name path string true "Tag name" +// @Router /tags/{name} [get] +func (a *App) getTag(c *gin.Context) { + var tag models.Tag + tag.Name = c.Param("tag") + tag.Resources = a.service.Query(c, []string { tag.Name }) + c.JSON(http.StatusOK, tag) +} + + +// @BasePath /api/v1 + +// Tag godoc +// @Summary Add a resource to a tag +// @Schemes +// @Description Add a resource to a tag +// @Tags tag resource +// @Accept json +// @Produce json +// @Success 200 {object} tag +// @Param tag path string true "Tag name" +// @Param resource body string true "Resource JSON object" +// @Router /tags/{tag} [post] +func (a *App) createResource(c *gin.Context) { + var body map[string]string + tagName := c.Param("tag") + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + a.service.AddTag(c, tagName, body["resource"]) + + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +func (a *App) streamTags(c *gin.Context) { + c.Stream(func(w io.Writer) bool { + tags := a.service.Tags(c) + for _,v := range(tags) { + //jsonData, _ := json.Marshal(message) + c.SSEvent("message", v) + } + return false + }) + <-c.Writer.CloseNotify() +} + +func (a *App) streamResourceTags(c *gin.Context, resourceUrl string) { + c.Stream(func(w io.Writer) bool { + tags := a.service.ResourceTags(c, resourceUrl) + for _,v := range(tags) { + //jsonData, _ := json.Marshal(message) + c.SSEvent("message", v) + } + return false + }) + <-c.Writer.CloseNotify() +} + +func (a *App) getTags(c *gin.Context) { + if c.GetHeader("Content-Type") == "text/event-stream" { + a.streamTags(c) + } else { + c.JSON(http.StatusOK, a.service.Tags(c)) + } + return +} + +// @BasePath /api/v1 + +// Tag godoc +// @Summary Get resource tags +// @Schemes +// @Description Get resource to tags +// @Tags tag resource +// @Accept json +// @Produce json +// @Success 200 {object} models.Resource +// @Param resource path string true "Base64 encoded Resource URL" +// @Router /resources/{resource} [get] +func (a *App) getResourceTags(c *gin.Context) { + var result models.Resource + resourceUrl, e := base64.StdEncoding.DecodeString(c.Param("resource")) + if e != nil { + c.JSON(404, gin.H{"code": "PAGE_NOT_FOUND", "message": "Page not found"}) + } + result.Resource = string(resourceUrl) + if c.GetHeader("Content-Type") == "text/event-stream" { + a.streamResourceTags(c, result.Resource) + } else { + result.Tags = a.service.ResourceTags(c, result.Resource) + c.JSON(http.StatusOK, result) + } + return +} + +// @BasePath /api/v1 + +// Tag godoc +// @Summary Get resource tags +// @Schemes +// @Description Get resource to tags +// @Tags tag resource +// @Accept json +// @Produce json +// @Success 200 {object} models.Resource +// @Param resource query string true "Resource URL" +// @Router /resources [get] +func (a *App) getResourceTagsByQuery(c *gin.Context) { + var result models.Resource + result.Resource = c.DefaultQuery("resource", "") + if result.Resource == "" { + // return list of unfiltered Resource models + } else { + if c.GetHeader("Content-Type") == "text/event-stream" { + a.streamResourceTags(c, result.Resource) + } else { + result.Tags = a.service.ResourceTags(c, result.Resource) + c.JSON(http.StatusOK, result) + } + } + return +} + +func (a *App) BasePath() string { + return a.apiBasePath +} + +func (a *App) Run() { + a.router.Run(":8080") +} + +func (a *App) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + a.router.ServeHTTP(rw,req) +} + +func (a *App) queryFormHandler(c *gin.Context) { + var form query + c.Bind(&form) + terms := strings.Fields(form.Terms) + results := a.service.Query(c, terms) + c.HTML(http.StatusOK, "query.tmpl", gin.H{ "resources": results, "terms": terms }) +} + +func NewApp(connector service.DataConnector) *App { + if connector == nil { + connector = data.NewRedisConnector(redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + Password: "", + DB: 0, + })) + } + + a := &App { + apiBasePath: "/api/v1", + service: service.NewService(), + router: gin.Default(), + connector: connector, + } + + /* + gin.DebugPrintRouteFunc = func(httpMethod, absolutePath, handlerName string, nuHandlers int) { + log.Printf("endpoint %v %v %v %v\n", httpMethod, absolutePath, handlerName, nuHandlers) + } + */ + + docs.SwaggerInfo.BasePath = a.apiBasePath + + a.service.UseDataConnector(context.Background(), a.connector) + a.router.Use(cors.Default()) + a.router.GET("/swagger/*any", ginswagger.WrapHandler(swaggerfiles.Handler)) + + a.router.StaticFile("/favicon.ico", "./static/icons/tagger16-mb.ico") + a.router.Static("/icons", "./static/icons/") + a.router.LoadHTMLGlob("templates/*") + + api := a.router.Group(a.apiBasePath) + api.GET("/ping", a.Ping) + api.GET("/resources/:resource", SSEHeadersMiddleware(), a.getResourceTags) + api.GET("/resources", SSEHeadersMiddleware(), a.getResourceTagsByQuery) + api.GET("/tags", SSEHeadersMiddleware(), a.getTags) + //r.GET("/tags", SSEHeadersMiddleware(), getTags) + api.GET("/tags/:tag", a.getTag) + api.POST("/tags", a.createTag) + api.POST("/tags/:tag", a.createResource) + api.PUT("/tags/:tag", a.updateTag) + + a.router.GET("/tags", func(c *gin.Context) { + c.HTML(http.StatusOK, "resources.tmpl", gin.H{"title": "Resources", "tags": a.service.Tags(c) }) + }) + a.router.GET("/", func(c *gin.Context) { + c.HTML(http.StatusOK, "index.tmpl", gin.H{ "title": "Resources" }) + }) + a.router.POST("/", a.queryFormHandler) + return a +} + +func main() { + app := NewApp(nil) + app.Run() +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..28297dd --- /dev/null +++ b/main_test.go @@ -0,0 +1,222 @@ +package main + +import ( +_ "fmt" + "context" + "testing" + "net/http" + "net/http/httptest" +_ "net/url" +_ "io" + "github.com/stretchr/testify/assert" + "encoding/json" +_ "encoding/base64" + "strings" + "tagger/httptest/stream" + "github.com/gin-contrib/sse" + "tagger/tests/mocks" + "tagger/internal/models" + "encoding/base64" +) + +func TestRouter(t *testing.T) { + app := NewApp(nil) + assert.NotNil(t, app) +} + +func TestPingRoute(t *testing.T) { + app := NewApp(nil) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", app.BasePath() + "/ping", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + assert.Equal(t, "pong", w.Body.String()) +} + +func TestTagsRoute(t *testing.T) { + expected := []string { "search-engine", "evil", "done-that" } + var results []string + + m := &mocks.MockDataConnector { + InjectTags: func(context.Context) ([]string) { return expected }, + } + + app := NewApp(m) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", app.BasePath() + "/tags", nil) + req.Header.Set("Content-Type", "application/json") + app.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + + json.NewDecoder(w.Body).Decode(&results) + assert.Equal(t, len(expected), len(results)) + for i,v := range(results) { + assert.Equal(t, expected[i], v) + } +} + +func TestTagsStreamRoute(t *testing.T) { + expected := []string { "search-engine", "evil", "done-that" } + //var results []string + + m := &mocks.MockDataConnector { + InjectTags: func(context.Context) ([]string) { return expected }, + } + + app := NewApp(m) + + w := stream.NewRecorder() + req, _ := http.NewRequest("GET", app.BasePath() + "/tags", nil) + req.Header.Set("Content-Type", "text/event-stream") + go app.ServeHTTP(w, req) + + for !w.Flushed {} + w.Close() + + assert.Equal(t, 200, w.Code) + + events, _ := sse.Decode(w.Body) + assert.Equal(t, len(expected), len(events)) + for i,v := range(events) { + assert.Equal(t, expected[i], v.Data) + } +} + +func TestTagRoute(t *testing.T) { + expected := []string { "https://www.google.com", "https://www.bing.com" } + var result models.Tag + + m := &mocks.MockDataConnector { + InjectQuery: func(context.Context, []string) ([]string, error) { return expected, nil }, + } + + app := NewApp(m) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", app.BasePath() + "/tags/search-engine", nil) + app.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + json.NewDecoder(w.Body).Decode(&result) + assert.Equal(t, len(expected), len(result.Resources)) + assert.Equal(t, expected, result.Resources) +} + + +func TestTagStream(t *testing.T) { + app := NewApp(nil) + w := stream.NewRecorder() + req, _ := http.NewRequest("GET", app.BasePath() + "/tags", nil) + req.Header.Set("Content-Type", "text/event-stream") + go app.ServeHTTP(w, req) + + w.Close() + + assert.Equal(t, 200, w.Code) +} + +func TestTagPost(t *testing.T) { + expected := []string { "https://www.google.com", "https://www.bing.com", "https://www.yahoo.com" } + var result models.Tag + + m := &mocks.MockDataConnector { + InjectQuery: func(context.Context, []string) ([]string, error) { return expected, nil }, + } + + app := NewApp(m) + + w := httptest.NewRecorder() + jsonReader := strings.NewReader(`{ "resource": "https://www.yahoo.com" }`) + reqPost, _ := http.NewRequest("PUT", app.BasePath() + "/tags/search-engine", jsonReader) + + wget := httptest.NewRecorder() + reqGet, _ := http.NewRequest("GET", app.BasePath() + "/tags/search-engine", nil) + app.ServeHTTP(w, reqPost) + assert.Equal(t, 200, w.Code) + + app.ServeHTTP(wget, reqGet) + + assert.Equal(t, 200, wget.Code) + json.NewDecoder(wget.Body).Decode(&result) + assert.Contains(t, result.Resources, "https://www.yahoo.com") +} + +func TestTagUpdate(t *testing.T) { + expected := []string { "https://www.google.com", "https://www.bing.com", "https://www.yahoo.com" } + var result models.Tag + + m := &mocks.MockDataConnector { + InjectQuery: func(context.Context, []string) ([]string, error) { return expected, nil }, + InjectAddTag: func(context.Context, string, string) (error) { return nil }, + } + + app := NewApp(m) + + w := httptest.NewRecorder() + jsonReader := strings.NewReader(`{ "name": "search-engine", "resources": [ "https://www.yahoo.com" ] }`) + reqPost, _ := http.NewRequest("POST", app.BasePath() + "/tags", jsonReader) + + wget := httptest.NewRecorder() + reqGet, _ := http.NewRequest("GET", app.BasePath() + "/tags/search-engine", nil) + app.ServeHTTP(w, reqPost) + assert.Equal(t, 200, w.Code) + + app.ServeHTTP(wget, reqGet) + + assert.Equal(t, 200, wget.Code) + json.NewDecoder(wget.Body).Decode(&result) + assert.Contains(t, result.Resources, "https://www.yahoo.com") +} + +func TestTagAddResource(t *testing.T) { + expected := []string { "https://www.google.com", "https://www.bing.com", "https://www.yahoo.com" } + var result models.Tag + + m := &mocks.MockDataConnector { + InjectQuery: func(context.Context, []string) ([]string, error) { return expected, nil }, + InjectAddTag: func(context.Context, string, string) (error) { return nil }, + } + + app := NewApp(m) + + w := httptest.NewRecorder() + jsonReader := strings.NewReader(`{ "resource": "https://www.yahoo.com" }`) + reqPost, _ := http.NewRequest("POST", app.BasePath() + "/tags/search-engine", jsonReader) + + wget := httptest.NewRecorder() + reqGet, _ := http.NewRequest("GET", app.BasePath() + "/tags/search-engine", nil) + app.ServeHTTP(w, reqPost) + assert.Equal(t, 200, w.Code) + + app.ServeHTTP(wget, reqGet) + + assert.Equal(t, 200, wget.Code) + json.NewDecoder(wget.Body).Decode(&result) + assert.Contains(t, result.Resources, "https://www.yahoo.com") + +} + +func TestGetResourceTags(t *testing.T) { + + m := &mocks.MockDataConnector { + InjectTags: func(context.Context) ([]string) { return []string{ "search-engine", "news" } }, + InjectResourceHasTag: func(ctx context.Context, res string, tag string) bool { if(tag == "search-engine") { return true } else { return false } }, + } + + app := NewApp(m) + + w := httptest.NewRecorder() + + reqGet, _ := http.NewRequest("GET", app.BasePath() + "/resources/" + base64.StdEncoding.EncodeToString([]byte("https://www.yahoo.com")), nil) + reqGet.Header.Set("Content-Type", "application/json") + app.ServeHTTP(w, reqGet) + + assert.Equal(t, 200, w.Code) + result, e := models.NewResourceFromJson(w.Body) + assert.Equal(t, nil, e) + assert.Contains(t, result.Tags, "search-engine") +} diff --git a/static/icons/tagger128-bl.png b/static/icons/tagger128-bl.png new file mode 100644 index 0000000000000000000000000000000000000000..93f0f7148ddc65b1cac1b5649c0e5c1d7e829e63 GIT binary patch literal 1744 zcmZ{kdpy$%AIE>c&6dkHlIF-|J0|6RzwFQm)0T{LO0K!aam~uyqHNf6o=#^;X7 zl&Sy6i>ZE2vdOL*rD^ajO%qAZ{Qdrv7j@-V_QKS3O?6r|R&tMSX=SPPjC5o3C=+eu zo6vgm%QC(-9)o>-`b@(_59r)HaM4<`+@v~?rz5eZPA4FG`;9+pXn}(5XBCvHmg9q~ z!kva^BQ;z)on+mIc%?asWlc6B`8(YK=d<0WW2TLA^hvwDvD_I8equh1rDT5gMdGv} zyC6E?A=C5u>x_yFTvhqm_UsLh*;cNnj+@5}YnOSirv1(po31?Si6%jVy-{# zbPFYusZq>J-nFmwQ_r`nRsR8xcKsK8d_7J4TsZFa^o5jxmI8FbPh~DhVtx#^;Kth` z#1(Bz$tA;U*UCG5pdkcuoq1F5!Iuq4A0G>|VA&_{KzIpaJokbY$*FgizG<>uj(C|O zLwlSU6Hzp?29kzqA*Pu^3wtAO9DBNx1_7vIK)t!M-1OSN&vct)92@^~2Jwq=LRp=t2V|69luDhz~$%sc| zY^BwsjLe5mPP&B;DK`1v(?5Lp+Rr|63Pe}Al4LoI14cqofhl?ZZKe}jzfyW+wC$3H zhqAKJtc}p`(>kf?+)#9pj~B@5BR{>s|5hmOFP!c7$<`qM@jsPYdm6Gy)uskgUI`ER z7YCRUBIs8sHW90=uTedI`q~Sg9Gu}d3bfP*4Gh|S^PWPL#*`Vmc(A6jaQf}FRDYr3$wkur=-1igCz$YY{UG?-X+ zw9(VO*DRnG+0R2S?~7e+i9jEFRH;+urfL(MI24>>-pHaNEvp#!evCnrNP8X9mSy z>j3)h8DkQ{QU|}?1hbXbvpB(=L{3ZP1_VK0d<@}3^14N-BB~0O=bJGjp~DdH5>PD+ zygTlU$^c9afIE)MQ1wf(+w>&KSG|M(E{he?s9+M}4dfVDx6~sV1z=6!6OD}@2=BN! zj<2oMRYV2U9r-@!krm2SevqfuQ4R9_ zXck^vFV=-(+Xm~{(J4i@&*Pz`TVK#02C%m^00L|oay@kG+g1Ntp5D7aXyhRay}aE` z?>(_wXK0DE8ZdwcPdM||Ao+I(W~JzkSyWv8<4 zLa6YXUVvDTqbEE27`DLC%qdIeM}}JyLayXG9jj++0q6pa*&(oXJa_Bn9gW$`5OIm( ze8{RVc1R)ofEuJquTT&$tROH;&%zy1@`I_Fdu9rHy+?;%RzvSHWX`ary+{d|`sSlE zDVn+v=e2RU@HNPlUesbk$zTp>l)#>ZaPy_i5X?}6#-tv@^(T6->4!VPkd8avL zG8NO=xRB3r;oLk#!3iejiWJ*T!;%k|Q;N(FF&dh0IU7Ca~4(NV|= zW1I4Ey6u+fk?mP&V;f^C5d~_U&1sw6Se*x_DZ&<2OY6L8Jo_R>a#2;Rs+H5sb(|P2 k7*FZukum>g86Kp7)cCKfa;{&C_IC{+6Dbb0cK)gV1x8L3JOBUy literal 0 HcmV?d00001 diff --git a/static/icons/tagger128-wh.png b/static/icons/tagger128-wh.png new file mode 100644 index 0000000000000000000000000000000000000000..c4f55b8ade5a8ad06d186fd65144b5b7490fcc81 GIT binary patch literal 1950 zcmai!{X5eOAIHDj%)@LsI)wDFMjwAvkcYC^nqq9XhNP&YBVm(H zC=8*n%sg{Ux*lq_xI-uVaa`+Z&S&+EG0KYgyx=epiEygl6!uq!;q|5#I9H9MWc1^@uWjKbl(2{;^*6dxB66&nr!=67g!e#Uot>oL82-0oy$xw-B* z3GKh_+R@bk*vuE#4CAMn18O{Ov21hza7LY`Y7G+>|R~Xhq8=p zefqD?Sl8W;3wz8AY#+@m&op^j=EKWf3z&P^9cbwn@Q$TmKCI$B6HtOWGtcRIiGIkkl` zsKl>#-RVlvcdZ|i)j6wWZKoj-PJ|lU#y;KQdc^6|b~fQUO%ot>He8q&Y=QUao1@Dx z-^<{`soLb~s|hhh?^Z$h*Hv5X`0abFAC!Nfu&LDNuRcz(dK4)D)FeLX;@QDns=uh8 z^>BApQKfYpH&q2K5=jk0K_(n`(^vp+1swu{9=6j|krCM~fQ*Z~ zcrpAE8NkJdhmph2BdJm33rKguaqoXIwio~altOTI@l726D57QRQFipM$f(qz+vqt| zlJ$O%noUD%A7QHPtN|_o`yq6Ugh#pM3ZmE|v?cJPXFaYidI$ZpB-eBx~aIUf=2f|X{2HcALJQ>}1%@x#l zY1#}t1gfW}GtiF$s+78?KzQDAedjLh#oB?a(u$|R=wN1wa~8}w%WwZZ-R`rOLCe_% z!Tw8gh5DnA(Sghq`qE`6GMGb>X*t_0&d7*r(XGzKn%j#87$5nDp|-=tvwwb=nrG_M z^DY4X4}{yq8LrgL=sDFgp6@1T=;g~DON;)%NHr$LxC$8UIfIe!g{%E$>HpB02*R^3 zi3wCTB-<-7_)ZyP@w25hhB#zvmdbNEM{kI-sSoBiI`3V)=XSymgg3#+O|wet_#XTd z2yjA00D6~Ay#b?+|CT@$3SjoSd5lSMZoz>n|H>cfjW$kdifZ7z>l5Gca}fy)EhI-C=!ZE@1>uj4|$za6T_To>S6f>qTLbjQ*yR?G(0!M zsM9obqdyYP*@ZN72|#31Wp4b3toh0}6{W!Jg&uEnJ%0+BoNdDE89syFraFGh;g%%a zT}#8G@w0bWG4SHzVB?t9WRZ^bksleh)J-69KoE4d$R7Mr0vd_btJNG^l!h=J(hlK3 zhDF6)>~9yfbIL(66ErS&$QzJ^WE8Z5PeTQBUGv}v42pE4fSYpyh_^m#SKk<6Yt&>g zroVAmd+fbb#~fJK>by4N!@ZdqrxOEqf=xmG0A?%P1N65*9XkH9oKX5fks&T)9txW# zc6vFBBFoUDCo{$7%bK+gj$g~Tb{4?8c6-Ni?Tzc$V`ab}7_~w=VtP6)O2|1}1e?sA zZecAxj{}uKUPR~j?V(Q1*3qJGDfBnDV5cdKcwW}jtTgEwv?Q?lE_+6YDzi$`Pi+p` zi|uaf7oEDMpNbo?%f;KnrWT|%&GHqe?%kZfpKQ)xCDQ6vSn7l*|4FzcHCL9%X0W*O z6l$CM0pB(zTPn2_DM{>(O{w%|J?Z6(dQoWCe3#YF{cJlh7Cd#`80pho4xw%hnJ&~> zRsOOJ;lso`F-yY-M^bUGF3Tv=$;C~|gs4qBtwNsu`Om;EBWXlh*bTT)h{EoF1yeTH3cy_>Z=_lrtig=Vr6xnA>W z{y;F_KGbHJS)jc4rug;AQOm>W>1;R=ddZ9@{JcqqR683Tf$uQoA(lwk8f?zD_z%qK z`Mbln*b=tgPE+3WmzYrF<<v5Ei#XX9CD}>aTm@Kh97-%!t~4cY Q)zSdL&C`{0EcD<11*#T@A^-pY literal 0 HcmV?d00001 diff --git a/static/icons/tagger128.ico b/static/icons/tagger128.ico new file mode 100644 index 0000000000000000000000000000000000000000..ca998a2a67ee6934404703b14a363f22f86f6251 GIT binary patch literal 67646 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 literal 0 HcmV?d00001 diff --git a/static/icons/tagger128.png b/static/icons/tagger128.png new file mode 100644 index 0000000000000000000000000000000000000000..885afa721ac7ea296c90a1b23cc40887b5ceddce GIT binary patch literal 2377 zcmah~_g4~%7Y7tv;CiMxUK);ao8-y?t^_f)v@%DACFVjC96b#|!_=3Vso79_Ewpfz z?)~9rc)Gj5Bvm8<000bwb~+_g z%6}Ax2qVi5*9`ywX`zmeo)||*jp&#td}u^40B|_xT8=H6<*7jP@N&+{%yf2AGxa_s z^#p#)mj?G1f9t@I)-m2q)q!C$|9r~!jb00RwalI=W27=P#P=wc)|R>r=nTF#P4US2 zgr;mB$Eqn_UE{N>TlHu=@I-CzB^w<&f)UiAFZu&cv4eK@n9ghI0;jecO59&I9`66n z6;wA2mbFnRXq#^6C!I-+;>kC#KEZq3ncJslh%-lWjnF%tv29;1`mPp$iKMUGpW~tNHeZ&sklCMW0|}Kv-P0KgOb(QNAdABecz^v+jqzSux3nRWKiz`;awm>SQjUu z3bEzbm@E{DX!JQE0AQr~AAt$ak#Ry(jD$JiEVc-eRZ@`ua!JcaXepDNeMpW`k&(d! z62LJg7)J`eq!AxVx~$=XIpKMhDy<|e)4({{dnb&3n}~8N3Q_J@=f+ZET$hd={tW;9 zOeSQk;mk^n_+|5p)%7FSUmy-l-TTpY=Rv&=HVNlHlk~X#(v@z-T;;&h%04%|`7f=w zxD2nyd-sx}~BEHvgH3Di?|1skSACQcI&?z79r~zkP{W5BOJe3A-P?73= z!xf^3PoA8~IZ-vmMlgGsw6$*DR&s@#`T zwrufh`6kjzrr^ErVG<^Rg3}M>*^sfnJx29jAj1Y=26(BYQE_%D#bBC z%--LOY=ic#zI+n06cWy>I^YQ^TiV%U^u8S@S~~m~LsbV#+c2^oCywfAJP*Er3u8Z* z@g}aY_KlO?msSuJDi8Oj+Q8(Ugleo|k5AEs(9o8d$lX}%_4*rw(bVbWym z_3oG7^_zFQW#|W-&yd$}QMDjk>Mypv7oc47Qi%Ea`4X3r!G}Q`fm@P~Mer)Xq1eUB z($da4W0kC$pFe+obIsX|tYP{|cfWHRgN+AhqU;d{IarTAb8ZNfT%3v0M%_B@;&RU4 zzo#yWKp;d!oK1D0rD@KsPQ?iyNQdf#HA5ByvnlZ>z~Hi>q~v$xibW_(t5dyoOp zM(HLLaL*TNYM6F< zK9Q{l{1EQ68EU!j2kqe_5?@`I>&rnT>>zypG@V-~IGYcc%NIqUp!>xwY`=11g&nXd zVl4F)iYAyCv2qQ-nM7Dyk6Bq+o%6OYudFPg)A7!a6giw^kTmE}?<(rxjVeZMi7e60XYFmKvz%le(=RORyA=6G3s>ST`YO1kZ(<6KrR;{~f0x+P ziBHxw+{|X2{%~lswn6aX4O=~)ulLq%Nz87UgN)&CX}3;28`9lnC$lDQ`~yOAImk2J zdE}h<1kB?F&R$Jp@_9q=1}buqOm$ECFR8jBHokjKi+Yf4V1ijghWA~c z3OR+1wyHU}GkQS!>qi4F!Zn(;5J<^oB6}k$6pmlipG}oYoR7psjl)*KcK2|c0R07O zElX}*q4k@8Nl!$ncgaQl_Ec$?KV@j|>daP0#wg2c{So%$0`?~>I_rW5P?P4Hl$rj& c!2AVVdwR61TUrq)e5e47v%6D;!-bT80lrggdH?_b literal 0 HcmV?d00001 diff --git a/static/icons/tagger16-mb.ico b/static/icons/tagger16-mb.ico new file mode 100644 index 0000000000000000000000000000000000000000..dd829131743e0286872e707e40bb99c413492c0a GIT binary patch literal 5355 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 V9&TRTa@XMF{|kdou7Lmm literal 0 HcmV?d00001 diff --git a/static/icons/tagger16-mw.ico b/static/icons/tagger16-mw.ico new file mode 100644 index 0000000000000000000000000000000000000000..32ab825d091bef6ed5a94e97a21f084e7ee6bb47 GIT binary patch literal 1346 zcmeH{J8pwO7=%CK81fFpF-e>kWs3AzT!o4XiI&25K%(UkU=S^(D{A`)G;ji1yhK)n zT)2spidoG{^FOq!DF!5Q)LX=T!S@t&;7d>yTnP5{Q;;MHS(Z^01zp!MO_SwviDg;X zwvFRBxUP%mdHBB1YPBK=0>UsPiX!4TCP@<3>osYbl4Tiro>LSBo6Ux@EZJ_i>~=e< zs-muInx>&`Te_}0^|i4#Ixsr$pAKBkX4?Iorp>R9UR9supNe*Xr1Uuh#sQND5kUhz p7opQ{{PvkXzYX-c{%O1q^s5*g>X#!uxmg$=gZVSKdKCKa*aP)&L4E)L literal 0 HcmV?d00001 diff --git a/static/icons/tagger16.ico b/static/icons/tagger16.ico new file mode 100644 index 0000000000000000000000000000000000000000..5805152748f19236e904241444e565d9dab15418 GIT binary patch literal 1342 zcmchXy-UMD9ERVBT5(XZrh^@t`b7mdb#fC?{|=W<>R|mqU5cyF+5Q8eAeM?)af*8u z!O{#7(y1v3LMOq>`5tYwLuow}FFd*2@9y{9<#Ghjc!kHnyMe?25CM=NVPX&&|IZ?X zSR#kyxK;16J|%l(o1Bm|+32<|_J_$L8SOD{h+{TMhHP~ktI|i;I!SfPsn_dA<#O5X zMXgrL$b5Pv7oA+yYBih79ZRT zQE~n4+NW<;#g)(Jh41^1FUEpdX}TJ8Jdgubmpm`>Ovw44Fgbq(Jxj-3%QdI6u69?D zN00oDD<<@K5aTv1ljILr3v$})IR8d}S%mOlg*;>`9z=uBg9Jpg7zeC=!U?~SZ5a=7 HJaaz*-#QnZ literal 0 HcmV?d00001 diff --git a/static/icons/tagger256-bl.png b/static/icons/tagger256-bl.png new file mode 100644 index 0000000000000000000000000000000000000000..26c62986751f9d89650f3fe629cfef7085abd6cb GIT binary patch literal 3346 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 literal 0 HcmV?d00001 diff --git a/static/icons/tagger32-wh.png b/static/icons/tagger32-wh.png new file mode 100644 index 0000000000000000000000000000000000000000..8ea71596848f9c4064e41df63a353bfff0e4d7dc GIT binary patch literal 846 zcmV-U1F`&xP)EX>4Tx04R}tkv&MmP!xqvQ$>-AibX^mGE^rEq9Tr3g(6f4wL+^7CNKSiCJjl7 zi=*ILaPVib>fqw6tAnc`2>yULJ2)x2NQw6)g%&Yhc)XAE?m4`7A0X69Otad?0Zq5f zWIQIOGpl09D+Kf+fIbY!%rfRADFNU5x~ER6yC~1{@B6d5)vU#UfJi*c4AUmwAfDc| z4bJ<-VOEe;;&b9LlP*a7$aTfzH_myN1)do;Q^|SaFtM0#VWovx!PJPSh$E_|Q@)V) zSmnIMSu0go`Uwzx2Cnp`zgz=mK1r`O zwa5|BvkhEaH#KDsxZD8-pA6ZQT`5RQ$mM|dGy0|s(0>bbt$A~6oa6KXNYShkH^9Lm zFq)_Ab)R>4w$AO}p2qxs00kp*du?LP9smFU24YJ`L;wH)0002_L%V+f000SaNLh0L z04^f{04^f|c%?sf00007bV*G`2j~J40Wl1CI^LB4000?uMObu0Z*6U5Zgc=ca%Ew3 zWn>_CX>@2HM@dakSAh-}0003cNklKQBXZ6o#KuwKQQ7I@oMX#40g%#;SvU z0;|y^5}UmSU%(;~gO4CAHfrr)6>Z!;mNulhiI$t&i2IJ`_nz~6lk*n}6e#fD5#JHi z22dU`-g`teGgauANb#Rm>zz*LI#&VejJmJ3RlD8pb6Wgz);aCsYM-TR;s+fP3Hpcp0M30V}{NFa=xz7ZLHA$vyBz&=Rl+EDt@kfTxIfEY7O}1^y&H Y06L7ZNbSDlZU6uP07*qoM6N<$g6v6bEC2ui literal 0 HcmV?d00001 diff --git a/static/icons/tagger32-whg.png b/static/icons/tagger32-whg.png new file mode 100644 index 0000000000000000000000000000000000000000..84e22c2107bcb42f2b99ad6ba0d1a6bb31dde5a1 GIT binary patch literal 954 zcmV;r14aCaP)EX>4Tx04R}tkv&MmP!xqvQ$>-AibX^mGE^rEq9Tr3g(6f4wL+^7CNKSiCJjl7 zi=*ILaPVib>fqw6tAnc`2>yULJ2)x2NQw6)g%&Yhc)XAE?m4`7A0X69Otad?0Zq5f zWIQIOGpl09D+Kf+fIbY!%rfRADFNU5x~ER6yC~1{@B6d5)vU#UfJi*c4AUmwAfDc| z4bJ<-VOEe;;&b9LlP*a7$aTfzH_myN1)do;Q^|SaFtM0#VWovx!PJPSh$E_|Q@)V) zSmnIMSu0go`Uwzx2Cnp`zgz=mK1r`O zwa5|BvkhEaH#KDsxZD8-pA6ZQT`5RQ$mM|dGy0|s(0>bbt$A~6oa6KXNYShkH^9Lm zFq)_Ab)R>4w$AO}p2qxs00kp*du?LP9smFU24YJ`L;(K){{a7>y{D4^000SaNLh0L z04^f{04^f|c%?sf00007bV*G`2j~J40W>vQNTS#P000?uMObu0Z*6U5Zgc=ca%Ew3 zWn>_CX>@2HM@dakSAh-}0004zNklze~eV5XZk)+YW+VwC&*LP*4|f%O*H< zu2Yu|*3G#~Cr2IJM8QP_o$BHuZi0V75sF=^2I?ZIkTgMBlK0M`Rnk)EuQVX$18)fL z`0{=4?%f3l2nhJ^(69hz&I1UKsOJ#Th1UjnEJ30k%~I(m(rg-8UDx*ug@V!_AD-3K zR;$$oUDx*-jYiS3%!HIu9~9@g0cK8EmQ~a=EguSnVgSHk2q7F_3;;MLqFhfRI!PR8 zAT+BKFn5*<;g^9!Ddoe@(Oos{j{zd8#$vH;$8olpx!kP_0I+>cK`NDcR1{@X2$5rE z^|umcZGf4R?(a+{bKPpS)}@pOlM0T@k!hM|_4j(xs}qd{0B8*@S*z8q%H?u=nEohz z;Bu6f?SAp`rT9msQaKkwW&xZ`Wq^owC%L&uB$98p+o!&cR%Rw<-Z2d0PF2z>% literal 0 HcmV?d00001 diff --git a/static/icons/tagger32.ico b/static/icons/tagger32.ico new file mode 100644 index 0000000000000000000000000000000000000000..03c72c70794a66a806496417d1e981653f744de0 GIT binary patch literal 4286 zcmeH}KT9J)7{;F%&I{g!7~Qyfm?&DDR{n|Qd9hKeE40)?R1j1GTAY3bJ3C7wD#VDb zUK4Z2m4^iK5hR^f{zD1k_zXC>uEfmlX(8-}UuI|C+4q-yvzZLwZ7*zoCPit9*2SBT3bF+ZGB1k;!DVkaMMn zUX#dYA>!7nu5;?AbA_xCQ{{NtHa_iE=(+Ge^QIB@r(uMDL_bupJr6teTQiKH3iN&_ zlW9b{C)(SrVXbqw{=YC285lXb_sLJvJ?)QbIjxhN`tQ)vd4lG{BQ%L!M9%3wc<}ek z$G9sBTC-O^*Y#(!Sy3vL#7*w^<|Sgn=3ip{dz1BCro*2qOD!{;`b+d*J^C+r@4pEB zMttsz0iQEHbp3LTkaMpQYdA&o>OVA|vHr@xEd5Edi+ASiH{jH-O1B3i$megC-fbfN zeGBGS+N;$Rt=6qyB~PQaitbLyVqF-)fQsm~T*sz}{Xf54f9j>IJ-z@Oe*G_{^I~2F vyb8SY3UF;HfBZ>^Tlg72Cb!(xekK9JZ8kop1VSwwBd{UIWGqxZ_e10!52y*k literal 0 HcmV?d00001 diff --git a/static/icons/tagger4.png b/static/icons/tagger4.png new file mode 100644 index 0000000000000000000000000000000000000000..8e783774177cdae25e665cac4902d127804eaf27 GIT binary patch literal 1381 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 IM6N<$f+r~6AOHXW literal 0 HcmV?d00001 diff --git a/static/icons/tagger64-wh.png b/static/icons/tagger64-wh.png new file mode 100644 index 0000000000000000000000000000000000000000..c7021babf9548000bb9ce2ad9c0f7741a4c21f3b GIT binary patch literal 1179 zcmV;M1Z4Y(P)EX>4Tx04R}tkv&MmP!xqvQ$>-AibX^mGE^rEq9Tr3g(6f4wL+^7CNKSiCJjl7 zi=*ILaPVib>fqw6tAnc`2>yULJ2)x2NQw6)g%&Yhc)XAE?m4`7A0X69Otad?0Zq5f zWIQIOGpl09D+Kf+fIbY!%rfRADFNU5x~ER6yC~1{@B6d5)vU#UfJi*c4AUmwAfDc| z4bJ<-VOEe;;&b9LlP*a7$aTfzH_myN1)do;Q^|SaFtM0#VWovx!PJPSh$E_|Q@)V) zSmnIMSu0go`Uwzx2Cnp`zgz=mK1r`O zwa5|BvkhEaH#KDsxZD8-pA6ZQT`5RQ$mM|dGy0|s(0>bbt$A~6oa6KXNYShkH^9Lm zFq)_Ab)R>4w$AO}p2qxs00kp*du?LP9smFU24YJ`L;wH)0002_L%V+f000SaNLh0L z04^f{04^f|c%?sf00007bV*G`2j~J40Wl_{ffqvn000?uMObu0Z*6U5Zgc=ca%Ew3 zWn>_CX>@2HM@dakSAh-}0007VNklp z#Q1&ZSTYSIwKwpReBfo@65B@Jdm0NWdYv%m?UZthp97Ql8Ja1J;)-;z`c zkk9A$=W@9opzSY93#k#nb{%jOI1g+pvbd!t0$PAhpsRT2f67+u%3PHTP!C)OdP|I& znY0M-izStM;8nW-Gr$?(QdR|c4cr3;ODS!ILg7nlu7IRTNhg6Vz=I<9EKOSuK++3I z`+z;bK-QArTp=Dw+G+b3@NS_m!9^#)Z^LuBK45c_B&YuQoJBp%kiJX00jvkEB~=4S z`Ai5fM~KgodXnz&b}3h&+6N*ZZFkzPFU6+u@$tsA2ry;)rR`6)du=Z(doHQ+=$??Y z5$FJR18;11R9n0H-)4fgyKGO}zLH4+lHY4hL^za%EAXd`|2%1rFPG!IatuVS0?ifj z_x(Jd4*(a*BDQ6_-}c=sI^STsFL@udWX<`*w#RIb+HTE?(@nNd*&eh#WBXRQA44_& z8j8RX;5g6(i~^5=Cz75m(CJoSSJL;l1Jl4w;DDsjvR9Qd{7db?-o)q~KrJu?3e0mG6^5ClOG1VIo4K@bF$;s=%R$u$|-lP3TG002ovPDHLkV1oN$0ww?e literal 0 HcmV?d00001 diff --git a/static/icons/tagger64-whg.png b/static/icons/tagger64-whg.png new file mode 100644 index 0000000000000000000000000000000000000000..7d772966c1a94d01a7d66e6bed7f4e973f74c74b GIT binary patch literal 1387 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 literal 0 HcmV?d00001 diff --git a/templates/footer.tmpl b/templates/footer.tmpl new file mode 100644 index 0000000..308b1d0 --- /dev/null +++ b/templates/footer.tmpl @@ -0,0 +1,2 @@ + + diff --git a/templates/header.tmpl b/templates/header.tmpl new file mode 100644 index 0000000..3b401c8 --- /dev/null +++ b/templates/header.tmpl @@ -0,0 +1,17 @@ + + + + + {{ .title }} + + + + + + + + + + + + diff --git a/templates/index.tmpl b/templates/index.tmpl new file mode 100644 index 0000000..ab0408f --- /dev/null +++ b/templates/index.tmpl @@ -0,0 +1,39 @@ +{{ template "header.tmpl" . }} + + +
+
+
+
+
+
+
+
+

Tagger

+
+
+ +
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +{{ template "footer.tmpl" . }} diff --git a/templates/query.tmpl b/templates/query.tmpl new file mode 100644 index 0000000..e77cecd --- /dev/null +++ b/templates/query.tmpl @@ -0,0 +1,18 @@ +{{ template "header.tmpl" . }} + +Terms: {{ range .terms }}{{ . }} {{ end }} + +
+
+{{ range .resources }} + +
+ +
+
{{ . }}
+

+ {{ . }} +
+
+ +{{ end }} diff --git a/templates/tags.tmpl b/templates/tags.tmpl new file mode 100644 index 0000000..5928fec --- /dev/null +++ b/templates/tags.tmpl @@ -0,0 +1,11 @@ +{{ template "header.tmpl" . }} + + +{{ range .tags }} + + + +{{ end }} +
+{{ . }} +
diff --git a/tests/mocks/data_connector.go b/tests/mocks/data_connector.go new file mode 100644 index 0000000..dd3794b --- /dev/null +++ b/tests/mocks/data_connector.go @@ -0,0 +1,36 @@ +package mocks + +import ( + "context" +) + +type MockDataConnector struct { + InjectConnected func(context.Context) bool + InjectQuery func(context.Context, []string) ([]string, error) + InjectTags func(context.Context) []string + InjectAddTag func(context.Context, string, string) error + InjectResourceHasTag func(context.Context, string, string) bool +} + +func (m *MockDataConnector) Connected(context context.Context) bool { + if m.InjectConnected == nil { + return true + } + return m.InjectConnected(context) +} + +func (m *MockDataConnector) Query(context context.Context, terms []string) ([]string, error) { + return m.InjectQuery(context, terms) +} + +func (m *MockDataConnector) Tags(context context.Context) []string { + return m.InjectTags(context) +} + +func (m *MockDataConnector) AddTag(context context.Context, TagName string, Resource string) error { + return m.InjectAddTag(context, TagName, Resource) +} + +func (m *MockDataConnector) ResourceHasTag(context context.Context, Resource string, TagName string) bool { + return m.InjectResourceHasTag(context, Resource, TagName) +} diff --git a/tests/mocks/http_client.go b/tests/mocks/http_client.go new file mode 100644 index 0000000..5717a60 --- /dev/null +++ b/tests/mocks/http_client.go @@ -0,0 +1,16 @@ +package mocks + +import ( +_ "testing" + "net/http" +) + +type HttpClientRoundTrip func(req *http.Request) *http.Response + +func (h HttpClientRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { + return h(r), nil +} + +func NewHttpClient(h HttpClientRoundTrip) *http.Client { + return &http.Client{ Transport: h } +}