add gin service

This commit is contained in:
Matthew Rich 2024-04-04 13:30:41 -07:00
parent 4ffc5d6a4a
commit 9c0511afcc
62 changed files with 2788 additions and 1 deletions

View File

@ -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

View File

@ -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"

View File

@ -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 }}."

11
Makefile Normal file
View File

@ -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 ./...

View File

@ -1,3 +1,4 @@
# tagger
Tagging service
REST API for creating resource tags
Provides a REST API and swagger docs for tagging resources.

5
build_deps.yml Normal file
View File

@ -0,0 +1,5 @@
- make
- libc-dev
- git
- openssh-client
- gcc

169
cmd/cli/main.go Normal file
View File

@ -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)
}

72
cmd/cli/main_test.go Normal file
View File

@ -0,0 +1,72 @@
package main
import (
"io"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/net/html"
)
/*
<meta name="title" content="Yahoo Finance - Stock Market Live, Quotes, Business &amp; Finance News">
<meta name="description" content="At Yahoo Finance, you get free stock quotes, up-to-date news, portfolio management resources, international market data, social interaction and mortgage rates that help you manage your financial life.">
<meta name="keywords" content="401k, Business, Financial Information, Investing, Investor, Market News, Stock Research, Stock Valuation, business news, economy, finance, investment tools, mortgage, mutual funds, personal finance, quote, real estate, retirement, stock, stocks, Suze Orman, tax, track portfolio">
<meta http-equiv="x-dns-prefetch-control" content="on">
<meta name="theme-color" content="#037B66">
<meta name="oath:guce:consent-host" content="guce.yahoo.com">
<meta property="twitter:dnt" content="on">
<meta name="twitter:site" content="@YahooFinance">
<meta property="fb:app_id" content="458584288257241">
<meta property="fb:pages" content="458584288257241">
<meta property="og:image" content="https://s.yimg.com/cv/apiv2/social/images/yahoo_default_logo.png">
<meta property="og:description" content="At Yahoo Finance, you get free stock quotes, up-to-date news, portfolio management resources, international market data, social interaction and mortgage rates that help you manage your financial life.">
<meta property="og:title" content="Yahoo Finance - Stock Market Live, Quotes, Business &amp; Finance News">
<meta property="twitter:description" content="At Yahoo Finance, you get free stock quotes, up-to-date news, portfolio management resources, international market data, social interaction and mortgage rates that help you manage your financial life.">
<meta property="twitter:title" content="Yahoo Finance - Stock Market Live, Quotes, Business &amp; Finance News">
<meta property="al:ios:app_store_id" content="328412701">
<meta property="al:ios:app_name" content="Yahoo Finance">
<meta property="al:android:url" content="intent://#Intent;scheme=yfinance;action=android.intent.action.VIEW;package=com.yahoo.mobile.client.android.finance;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.yahoo.mobile.client.android.finance;end">
<meta property="al:android:app_name" content="Yahoo Finance">
<meta property="al:android:package" content="com.yahoo.mobile.client.android.finance">
<meta property="apple-itunes-app" content="app-id=328412701, app-clip-bundle-id=com.yahoo.finance.clip-qsp, affiliate-data=ct=us.fin.mbl.smart-banner&pt=9029, app-argument=https://yfinance.onelink.me/3068494570?pid=AppPromo&c=US_Acquisition_YMktg_337__&af_sub1=Acquisition&af_sub2=US_YMktg&af_sub3=&af_sub4=100000591&af_sub5=SmartBanner__SmartBanner_">
*/
func TestExtractUrl(t *testing.T) {
htmlData := `
<html><head>
<meta name="title" content="Yahoo Finance - Stock Market Live, Quotes, Business &amp; Finance News">
<meta name="description" content="At Yahoo Finance, you get free stock quotes, up-to-date news, portfolio management resources, international market data, social interaction and mortgage rates that help you manage your financial life.">
<meta name="keywords" content="401k, Business, Financial Information, Investing, Investor, Market News, Stock Research, Stock Valuation, business news, economy, finance, investment tools, mortgage, mutual funds, personal finance, quote, real estate, retirement, stock, stocks, Suze Orman, tax, track portfolio">
<meta name="theme-color" content="#037B66">
</head><body></body></html>`
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 := `
<html><head>
<meta name="title" content="Yahoo Finance - Stock Market Live, Quotes, Business &amp; Finance News">
<meta name="description" content="At Yahoo Finance, you get free stock quotes, up-to-date news, portfolio management resources, international market data, social interaction and mortgage rates that help you manage your financial life.">
<meta name="keywords" content="401k, Business, Financial Information, Investing, Investor, Market News, Stock Research, Stock Valuation, business news, economy, finance, investment tools, mortgage, mutual funds, personal finance, quote, real estate, retirement, stock, stocks, Suze Orman, tax, track portfolio">
<meta name="theme-color" content="#037B66">
</head><body></body></html>`
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))
}

9
data/data.go Normal file
View File

@ -0,0 +1,9 @@
package data
type Reader interface {
Read()
}
type Connector interface {
Connect() DataSource
}

28
data/data_test.go Normal file
View File

@ -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{})
}

318
docs/docs.go Normal file
View File

@ -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)
}

294
docs/swagger.json Normal file
View File

@ -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"
}
}
}
}
}
}

194
docs/swagger.yaml Normal file
View File

@ -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"

55
go.mod Normal file
View File

@ -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
)

161
go.sum Normal file
View File

@ -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=

1
go_deps.yml Normal file
View File

@ -0,0 +1 @@
- github.com/swaggo/swag/cmd/swag@latest

26
httptest/stream/stream.go Normal file
View File

@ -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),
}
}

View File

@ -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")
}

47
internal/client/client.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

61
internal/data/redis.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -0,0 +1,5 @@
package models
import (
)

View File

@ -0,0 +1,9 @@
package models
import (
"testing"
)
func TestNewModel(t *testing.T) {
}

View File

@ -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
}

View File

@ -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")
}

25
internal/models/tag.go Normal file
View File

@ -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
}

View File

@ -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")
}

13
internal/service/data.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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")
}

348
main.go Normal file
View File

@ -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()
}

222
main_test.go Normal file
View File

@ -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")
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
static/icons/tagger128.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
static/icons/tagger128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
static/icons/tagger16.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

BIN
static/icons/tagger32.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
static/icons/tagger4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
static/icons/tagger48.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
static/icons/tagger64.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
static/icons/tagger96.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

2
templates/footer.tmpl Normal file
View File

@ -0,0 +1,2 @@
</body>
</html>

17
templates/header.tmpl Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html>
<head>
<title>{{ .title }}</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="favicon.ico">
<!--Use bootstrap to make the application look nice-->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
</head>
<body>

39
templates/index.tmpl Normal file
View File

@ -0,0 +1,39 @@
{{ template "header.tmpl" . }}
<style>
.tagger-icon {
background-size: 20px;
background: url("icons/tagger32-whg.png") no-repeat scroll left;
padding-left: 30px
}
</style>
<br/>
<br/>
<br/>
<br/>
<center>
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-4">
<h1 class="display-1">Tagger</h1>
</div>
</div>
<form action="/" method="POST" class="form-inline">
<div class="row justify-content-center">
<div class="col-4">
<div class="form-group mb-2">
<input class="form-control tagger-icon" style="background-color: black; color: white; font-size: 18pt;" type="text" name="terms[]" id="query">
</div>
</div>
</div>
<div class="row justify-content-center">
<div class="col-4">
<button type="submit" class="btn btn-primary mb-2">Search</button>
</div>
</div>
</form>
</div>
</center>
{{ template "footer.tmpl" . }}

18
templates/query.tmpl Normal file
View File

@ -0,0 +1,18 @@
{{ template "header.tmpl" . }}
Terms: {{ range .terms }}{{ . }} {{ end }}
<br />
<br />
{{ range .resources }}
<div class="card" style="width: 18rem;">
<img class="img-circle" width="32" height="32" src="{{ . }}/favicon.ico">
<div class="card-body">
<h5 class="card-title">{{ . }}</h5>
<p class="card-text"></p>
<a href="{{ . }}">{{ . }}</a>
</div>
</div>
{{ end }}

11
templates/tags.tmpl Normal file
View File

@ -0,0 +1,11 @@
{{ template "header.tmpl" . }}
<table>
{{ range .tags }}
<tr>
<td>
{{ . }}
</td>
</tr>
{{ end }}
</table>

View File

@ -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)
}

View File

@ -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 }
}