Skip to content

Commit

Permalink
feat: Add Content-Type middleware (#21)
Browse files Browse the repository at this point in the history
* feat: Add mapping between api.Item and item.Item

* feat: Add MediaType middleware function

* refactor: Nest library code in libs folder

* fix: Empty content type should also return error

* fix: Linting issues
  • Loading branch information
MaikelVeen authored Aug 17, 2024
1 parent 7d9eb4d commit 3763605
Show file tree
Hide file tree
Showing 17 changed files with 431 additions and 14 deletions.
8 changes: 4 additions & 4 deletions .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ linters:
- loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap)
- makezero # finds slice declarations with non-zero initial length
- mirror # reports wrong mirror patterns of bytes/strings usage
- mnd # detects magic numbers
# mnd # detects magic numbers
- musttag # enforces field tags in (un)marshaled structs
- nakedret # finds naked returns in functions greater than a specified function length
- nestif # reports deeply nested if statements
Expand Down Expand Up @@ -290,14 +290,14 @@ linters:
## you may want to enable
#- decorder # checks declaration order and count of types, constants, variables and functions
#- exhaustruct # [highly recommend to enable] checks if all structure fields are initialized
#- gci # controls golang package import order and makes it always deterministic
- gci # controls golang package import order and makes it always deterministic
#- ginkgolinter # [if you use ginkgo/gomega] enforces standards of using ginkgo and gomega
#- godox # detects FIXME, TODO and other comment keywords
#- goheader # checks is file header matches to pattern
- goheader # checks is file header matches to pattern
#- inamedparam # [great idea, but too strict, need to ignore a lot of cases by default] reports interfaces with unnamed method parameters
#- interfacebloat # checks the number of methods inside an interface
#- ireturn # accept interfaces, return concrete types
#- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated
- prealloc # [premature optimization, but can be used in some cases] finds slice declarations that could potentially be preallocated
#- tagalign # checks that struct tags are well aligned
#- varnamelen # [great idea, but too many false positives] checks that the length of a variable's name matches its scope
#- wrapcheck # checks that errors returned from external packages are wrapped
Expand Down
23 changes: 23 additions & 0 deletions api/item.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package api

import (
"github.com/glass-cms/glasscms/item"
"github.com/glass-cms/glasscms/parser"
)

// MapToDomain converts an api.Item to an item.Item.
func (i *Item) MapToDomain() *item.Item {
if i == nil {
return nil
}
return &item.Item{
UID: i.Id,
Name: i.Name,
Path: i.Path,
Content: i.Content,
Hash: parser.HashContent([]byte(i.Content)),
CreateTime: i.CreateTime,
UpdateTime: i.UpdateTime,
Properties: i.Properties,
}
}
130 changes: 130 additions & 0 deletions api/item_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package api_test

import (
"testing"
"time"

"github.com/glass-cms/glasscms/api"
"github.com/glass-cms/glasscms/item"
"github.com/glass-cms/glasscms/parser"
"github.com/stretchr/testify/assert"
)

func TestItem_MapToDomain(t *testing.T) {
t.Parallel()

type fields struct {
Content string
CreateTime time.Time
DisplayName string
ID string
Name string
Path string
Properties map[string]interface{}
UpdateTime time.Time
}

tests := []struct {
name string
fields fields
want *item.Item
}{
{
name: "Complete Item Mapping",
fields: fields{
Content: "Test Content",
CreateTime: time.Now().Add(-24 * time.Hour),
DisplayName: "Test Display Name",
ID: "1234",
Name: "Test Name",
Path: "/test/path",
Properties: map[string]interface{}{"key1": "value1", "key2": "value2"},
UpdateTime: time.Now(),
},
want: &item.Item{
UID: "1234",
Name: "Test Name",
Path: "/test/path",
Content: "Test Content",
Hash: parser.HashContent([]byte("Test Content")),
CreateTime: time.Now().Add(-24 * time.Hour),
UpdateTime: time.Now(),
Properties: map[string]any{"key1": "value1", "key2": "value2"},
},
},
{
name: "Empty Item Mapping",
fields: fields{
Content: "",
CreateTime: time.Time{},
DisplayName: "",
ID: "",
Name: "",
Path: "",
Properties: nil,
UpdateTime: time.Time{},
},
want: &item.Item{
UID: "",
Name: "",
Path: "",
Content: "",
Hash: parser.HashContent([]byte("")),
CreateTime: time.Time{},
UpdateTime: time.Time{},
Properties: nil,
},
},
{
name: "Nil Properties Mapping",
fields: fields{
Content: "Test Content",
CreateTime: time.Now().Add(-24 * time.Hour),
DisplayName: "Test Display Name",
ID: "5678",
Name: "Test Name 2",
Path: "/test/path2",
Properties: nil,
UpdateTime: time.Now(),
},
want: &item.Item{
UID: "5678",
Name: "Test Name 2",
Path: "/test/path2",
Content: "Test Content",
Hash: parser.HashContent([]byte("Test Content")),
CreateTime: time.Now().Add(-24 * time.Hour),
UpdateTime: time.Now(),
Properties: nil,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

apiItem := &api.Item{
Content: tt.fields.Content,
CreateTime: tt.fields.CreateTime,
DisplayName: tt.fields.DisplayName,
Id: tt.fields.ID,
Name: tt.fields.Name,
Path: tt.fields.Path,
Properties: tt.fields.Properties,
UpdateTime: tt.fields.UpdateTime,
}
got := apiItem.MapToDomain()

// Adjust for potential differences in time (e.g., slight differences due to test execution timing)
if !got.CreateTime.IsZero() && !tt.want.CreateTime.IsZero() {
got.CreateTime = tt.want.CreateTime
}
if !got.UpdateTime.IsZero() && !tt.want.UpdateTime.IsZero() {
got.UpdateTime = tt.want.UpdateTime
}

assert.Equal(t, tt.want, got)
})
}
}
2 changes: 1 addition & 1 deletion cmd/docs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ func TestFilePrepender(t *testing.T) {
timestampStr := strings.Split(strings.Split(result, "createTime: ")[1], "\n")[0]
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
require.NoError(t, err)
require.Greater(t, timestamp, int64(0))
require.Positive(t, timestamp, int64(0))
}
12 changes: 11 additions & 1 deletion cmd/server/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import (
"os"
"os/user"

"github.com/glass-cms/glasscms/ctx"
"github.com/glass-cms/glasscms/database"
"github.com/glass-cms/glasscms/item"
ctx "github.com/glass-cms/glasscms/lib/context"
"github.com/glass-cms/glasscms/server"
"github.com/lmittmann/tint"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -76,11 +76,21 @@ func NewStartCommand() *StartCommand {
}

func (c *StartCommand) Execute(cmd *cobra.Command, _ []string) error {
c.logger.Debug("connecting to database",
slog.String("driver", c.databaseConfig.Driver),
slog.String("dsn", c.databaseConfig.DSN),
)

db, err := database.NewConnection(*c.databaseConfig)
if err != nil {
return err
}

// Ping the database to ensure the connection is valid.
if err = db.Ping(); err != nil {
return err
}

server, err := server.New(c.logger, item.NewRepository(db))
if err != nil {
return err
Expand Down
2 changes: 1 addition & 1 deletion config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
output: './out'
database:
dsn: ':memory:'
dsn: 'file:test.db?cache=shared&mode=memory'
driver: 'sqlite3'

3 changes: 3 additions & 0 deletions lib/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## Libs

The libs folder contains modules and files that are auxiliary to the application. Libraries contain utility functions that are non-specific to the application domain. They are used to encapsulate code that is used in multiple places in the application. The libs folder is not a place for domain-specific code.
2 changes: 1 addition & 1 deletion ctx/sigterm.go → lib/context/sigterm.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package ctx
package context

import (
"context"
Expand Down
8 changes: 8 additions & 0 deletions lib/mediatype/application.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package mediatype

const (
// ApplicationJSON json mime type.
ApplicationJSON string = "application/json"
// ApplicationXML xml mime type.
ApplicationXML string = "application/xml"
)
45 changes: 45 additions & 0 deletions lib/mediatype/mediatype.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Package mediatype provides types, constant and parsing functions for media types (MIME).
package mediatype

import (
"mime"
"strings"
)

// MediaType represents a media type (MIME).
//
// A MIME type most commonly consists of just two parts: a type and a subtype,
// separated by a slash (/) — with no whitespace between:.
type MediaType struct {
MediaType string

Type string
Subtype *string

// Parameters are optional key-value pairs that follow the type/subtype in a MIME type.
Parameters map[string]string
}

// Parse parses a media type string and returns a MediaType.
// If the string is not a valid media type, an error is returned.
func Parse(s string) (*MediaType, error) {
mt, params, err := mime.ParseMediaType(s)
if err != nil {
return nil, err
}

split := strings.Split(mt, "/")
var subtype *string

// If there is a subtype, set it.
if len(split) == 2 {
subtype = &split[1]
}

return &MediaType{
MediaType: mt,
Type: split[0],
Subtype: subtype,
Parameters: params,
}, nil
}
80 changes: 80 additions & 0 deletions lib/mediatype/mediatype_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Package mediatype provides types, constant and parsing functions for media types (MIME).
package mediatype_test

import (
"reflect"
"testing"

"github.com/glass-cms/glasscms/lib/mediatype"
)

func stringPtr(s string) *string {
return &s
}

func TestParse(t *testing.T) {
t.Parallel()

tests := []struct {
name string
input string
want *mediatype.MediaType
wantErr bool
}{
{
name: "application/json",
input: "application/json",
want: &mediatype.MediaType{
MediaType: "application/json",
Type: "application",
Subtype: stringPtr("json"),
Parameters: map[string]string{},
},
wantErr: false,
},
{
name: "application/json with charset",
input: "application/json; charset=utf-8",
want: &mediatype.MediaType{
MediaType: "application/json",
Type: "application",
Subtype: stringPtr("json"),
Parameters: map[string]string{"charset": "utf-8"},
},
wantErr: false,
},
{
name: "without subtype",
input: "text",
want: &mediatype.MediaType{
MediaType: "text",
Type: "text",
Subtype: nil,
Parameters: map[string]string{},
},
wantErr: false,
},
{
name: "invalid media type with slash",
input: "text/",
want: nil,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

got, err := mediatype.Parse(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
return
}

if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Parse() = %v, want %v", got, tt.want)
}
})
}
}
Loading

0 comments on commit 3763605

Please sign in to comment.