From cc9f4fd69cfa5fd509ce97c8af3207e3f626a359 Mon Sep 17 00:00:00 2001 From: Costin M <4030027+costinmrr@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:18:02 +0100 Subject: [PATCH] feat: implement json, xml, and csv (#1) * feat: implemented json & xml * chore: gh actions on push only for main * chore: tweaks * chore: go mod tidy * chore: use go 1.23 * feat: implemented csv * chore: fix linter --- .github/workflows/govulncheck.yml | 19 ++++++ .github/workflows/lint.yml | 18 ++++++ .github/workflows/test.yml | 18 ++++++ .golangci.yml | 22 +++++++ .idea/.gitignore | 8 +++ .idea/gontenttype.iml | 9 +++ .idea/modules.xml | 8 +++ .idea/vcs.xml | 6 ++ Makefile | 20 +++++++ go.mod | 3 + go.sum | 0 gontenttype.go | 26 ++++++++ types.go | 10 ++++ types/csv/csv.go | 20 +++++++ types/csv/csv_test.go | 77 ++++++++++++++++++++++++ types/csv/errors.go | 5 ++ types/json/json.go | 13 ++++ types/json/json_test.go | 76 ++++++++++++++++++++++++ types/xml/errors.go | 10 ++++ types/xml/xml.go | 60 +++++++++++++++++++ types/xml/xml_test.go | 98 +++++++++++++++++++++++++++++++ 21 files changed, 526 insertions(+) create mode 100644 .github/workflows/govulncheck.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test.yml create mode 100644 .golangci.yml create mode 100644 .idea/.gitignore create mode 100644 .idea/gontenttype.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 gontenttype.go create mode 100644 types.go create mode 100644 types/csv/csv.go create mode 100644 types/csv/csv_test.go create mode 100644 types/csv/errors.go create mode 100644 types/json/json.go create mode 100644 types/json/json_test.go create mode 100644 types/xml/errors.go create mode 100644 types/xml/xml.go create mode 100644 types/xml/xml_test.go diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml new file mode 100644 index 0000000..5125270 --- /dev/null +++ b/.github/workflows/govulncheck.yml @@ -0,0 +1,19 @@ +name: govulncheck + +on: + push: + branches: [main] + pull_request: + +jobs: + scan: + name: govulncheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + - name: govulncheck + continue-on-error: false + run: make govulncheck \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..dedb0de --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,18 @@ +name: lint + +on: + push: + branches: [main] + pull_request: + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + - name: golangci-lint + uses: golangci/golangci-lint-action@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e4a37a1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: ./go.mod + - name: test + run: make test diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..73db613 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,22 @@ +linters-settings: + revive: + rules: + - name: line-length-limit + arguments: [120] + +issues: + exclude-rules: + - path: _test\.go + linters: + - revive + text: "line-length-limit:" + +linters: + enable: + - thelper + - gofumpt + - tparallel + - unconvert + - unparam + - wastedassign + - revive diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/gontenttype.iml b/.idea/gontenttype.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/gontenttype.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..97d308e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..872c871 --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +export GO111MODULE=on + +tidy: + go mod tidy + +build: + go build + +test: + go test -v ./... + +fmt: + go fmt ./... + +lint: + golangci-lint run ./... + +govulncheck: + @go get golang.org/x/vuln/cmd/govulncheck + @go run golang.org/x/vuln/cmd/govulncheck ./... \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26ff12b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/costinmrr/gontenttype + +go 1.23 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/gontenttype.go b/gontenttype.go new file mode 100644 index 0000000..406bed7 --- /dev/null +++ b/gontenttype.go @@ -0,0 +1,26 @@ +package gontenttype + +import ( + "github.com/costinmrr/gontenttype/types/csv" + "github.com/costinmrr/gontenttype/types/json" + "github.com/costinmrr/gontenttype/types/xml" +) + +func GetContentType(content string) ContentType { + err := json.IsJSON(content) + if err == nil { + return JSON + } + + err = xml.IsXML(content) + if err == nil { + return XML + } + + err = csv.IsCSV(content) + if err == nil { + return CSV + } + + return Unsupported +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..8eab5da --- /dev/null +++ b/types.go @@ -0,0 +1,10 @@ +package gontenttype + +type ContentType string + +const ( + Unsupported ContentType = "" + JSON ContentType = "application/json" + XML ContentType = "application/xml" + CSV ContentType = "text/csv" +) diff --git a/types/csv/csv.go b/types/csv/csv.go new file mode 100644 index 0000000..6220a98 --- /dev/null +++ b/types/csv/csv.go @@ -0,0 +1,20 @@ +package csv + +import ( + "encoding/csv" + "strings" +) + +// IsCSV returns true if the content is a CSV. +func IsCSV(content string) error { + if content == "" { + return ErrEmptyContent + } + reader := csv.NewReader(strings.NewReader(content)) + _, err := reader.ReadAll() + if err != nil { + return err + } + + return nil +} diff --git a/types/csv/csv_test.go b/types/csv/csv_test.go new file mode 100644 index 0000000..a477cc2 --- /dev/null +++ b/types/csv/csv_test.go @@ -0,0 +1,77 @@ +package csv + +import "testing" + +func TestIsCSV(t *testing.T) { + type args struct { + content string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{content: ""}, + wantErr: true, + }, + { + name: "simple string is csv", + args: args{content: "foo"}, + wantErr: false, + }, + { + name: "csv comma separated", + args: args{content: "foo,bar\nbaz,qux"}, + wantErr: false, + }, + { + name: "csv semicolon separated", + args: args{content: "foo;bar\nbaz;qux"}, + wantErr: false, + }, + { + name: "csv tab separated", + args: args{content: "foo\tbar\nbaz\tqux"}, + wantErr: false, + }, + { + name: "csv pipe separated", + args: args{content: "foo|bar\nbaz|qux"}, + wantErr: false, + }, + { + name: "csv with quotes", + args: args{content: "\"foo\",\"bar\"\n\"baz\",\"qux\""}, + wantErr: false, + }, + { + name: "csv with quotes and commas", + args: args{content: "\"foo,bar\",\"baz,qux\"\n\"foo,bar\",\"baz,qux\""}, + wantErr: false, + }, + { + name: "different number of columns per line", + args: args{content: "foo,bar\nbaz"}, + wantErr: true, + }, + { + name: "empty lines", + args: args{content: "foo,bar\n\nbaz,qux"}, + wantErr: false, + }, + { + name: "different separators per line", + args: args{content: "foo,bar\nbaz;qux"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := IsCSV(tt.args.content); (err != nil) != tt.wantErr { + t.Errorf("IsCSV() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/types/csv/errors.go b/types/csv/errors.go new file mode 100644 index 0000000..3746c18 --- /dev/null +++ b/types/csv/errors.go @@ -0,0 +1,5 @@ +package csv + +import "errors" + +var ErrEmptyContent = errors.New("empty content") diff --git a/types/json/json.go b/types/json/json.go new file mode 100644 index 0000000..3b671f9 --- /dev/null +++ b/types/json/json.go @@ -0,0 +1,13 @@ +package json + +import "encoding/json" + +// IsJSON returns true if the content is a JSON. +func IsJSON(content string) error { + err := json.Unmarshal([]byte(content), new(interface{})) + if err != nil { + return err + } + + return nil +} diff --git a/types/json/json_test.go b/types/json/json_test.go new file mode 100644 index 0000000..fd7deee --- /dev/null +++ b/types/json/json_test.go @@ -0,0 +1,76 @@ +package json + +import "testing" + +func TestIsJSON(t *testing.T) { + type args struct { + content string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "empty", + args: args{content: ""}, + wantErr: true, + }, + { + name: "string not json", + args: args{content: "foo"}, + wantErr: true, + }, + { + name: "json", + args: args{content: `{"foo": "bar"}`}, + wantErr: false, + }, + { + name: "json with spaces", + args: args{content: ` { "foo": "bar" } `}, + wantErr: false, + }, + { + name: "json with newlines", + args: args{content: `{ +"foo": "bar" +}`}, + wantErr: false, + }, + { + name: "json with tabs", + args: args{content: `{ + "foo": "bar" + }`}, + wantErr: false, + }, + { + name: "json with other chars at the end", + args: args{content: `{"foo": "bar"}!`}, + wantErr: true, + }, + { + name: "json with other chars at the end 2", + args: args{content: `{"foo": "bar"}>>`}, + wantErr: true, + }, + { + name: "json with other chars at the end 3", + args: args{content: `{"foo": "bar"}}}`}, + wantErr: true, + }, + { + name: "json with other chars at the end 4", + args: args{content: `{"foo": "bar"}{"baz": "qux"}`}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := IsJSON(tt.args.content); (err != nil) != tt.wantErr { + t.Errorf("IsJSON() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/types/xml/errors.go b/types/xml/errors.go new file mode 100644 index 0000000..6974807 --- /dev/null +++ b/types/xml/errors.go @@ -0,0 +1,10 @@ +package xml + +import "errors" + +var ( + ErrEmptyContent = errors.New("empty content") + ErrSecondRootFound = errors.New("found a second root element") + ErrContentAfterRoot = errors.New("found content after root element was closed") + ErrRootNotFound = errors.New("root element not found") +) diff --git a/types/xml/xml.go b/types/xml/xml.go new file mode 100644 index 0000000..7e89277 --- /dev/null +++ b/types/xml/xml.go @@ -0,0 +1,60 @@ +package xml + +import ( + "bytes" + "encoding/xml" + "io" + "strings" +) + +// IsXML returns true if the content is an XML. +func IsXML(content string) error { + // If the content is empty, it is not an XML. + content = strings.TrimSpace(content) + if content == "" { + return ErrEmptyContent + } + + decoder := xml.NewDecoder(bytes.NewReader([]byte(content))) + var rootFound bool + var depth int + for { + tok, err := decoder.Token() + if err == io.EOF { + // Reached end of input with no extra content + break + } + if err != nil { + // XML parsing error + return err + } + // Check for extra content after XML root + switch tok.(type) { + case xml.StartElement: + if !rootFound { + // Found the root element + rootFound = true + } + // Increase depth + depth++ + case xml.EndElement: + // Decrease depth + depth-- + // If depth is 0, the root element was closed. Check for extra content + if depth == 0 { + if _, err := decoder.Token(); err != io.EOF { + // Found content after root element was closed + return ErrContentAfterRoot + } + // Valid XML with one root element and no extra content + return nil + } + } + } + + if !rootFound { + return ErrRootNotFound + } + + return nil +} diff --git a/types/xml/xml_test.go b/types/xml/xml_test.go new file mode 100644 index 0000000..86737a3 --- /dev/null +++ b/types/xml/xml_test.go @@ -0,0 +1,98 @@ +package xml + +import ( + "errors" + "testing" +) + +func TestIsXML(t *testing.T) { + type args struct { + content string + } + tests := []struct { + name string + args args + wantErr bool + inheritedErr bool + err error + }{ + { + name: "empty", + args: args{content: ""}, + wantErr: true, + err: ErrEmptyContent, + }, + { + name: "string not xml", + args: args{content: "foo"}, + wantErr: true, + err: ErrRootNotFound, + }, + { + name: "string not xml starting with <", + args: args{content: "bar"}, + wantErr: false, + }, + { + name: "xml with spaces at the end", + args: args{content: "bar "}, + wantErr: false, + }, + { + name: "xml with spaces at the beginning", + args: args{content: " bar"}, + wantErr: false, + }, + { + name: "xml with other chars at the end", + args: args{content: "bar>>>"}, + wantErr: true, + err: ErrContentAfterRoot, + }, + { + name: "xml with other chars at the end 2", + args: args{content: "bar<>"}, + wantErr: true, + err: ErrContentAfterRoot, + }, + { + name: "xml with other chars at the end 3", + args: args{content: `bar{"a": "b"}`}, + wantErr: true, + err: ErrContentAfterRoot, + }, + { + name: "complex xml", + args: args{content: "baz"}, + wantErr: false, + }, + { + name: "very complex xml", + args: args{content: `baz`}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := IsXML(tt.args.content) + if (err != nil) != tt.wantErr { + t.Errorf("IsXML() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.inheritedErr { + // If the error is inherited, we don't need to check the error + return + } + if !errors.Is(err, tt.err) { + t.Errorf("IsXML() error = %v, wantErr %v", err, tt.err) + return + } + }) + } +}