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