Skip to content

Commit

Permalink
Implement get item endpoint (#25)
Browse files Browse the repository at this point in the history
* chore: npm audit fix

* feat: Implement Get item end to end

* refactor: Rename MediaType middleware to ContentType

* feat: Add Accept middleware for checking the Accept header

* feat: Add default error in ErrorHandler

* test: Add test for ItemsGet

* chore: Fix linting issues
  • Loading branch information
MaikelVeen committed Sep 8, 2024
1 parent 4a58553 commit 1dd9273
Show file tree
Hide file tree
Showing 22 changed files with 458 additions and 67 deletions.
16 changes: 16 additions & 0 deletions api/v1/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,19 @@ func (i *ItemCreate) ToItem() *item.Item {
Metadata: i.Metadata,
}
}

func FromItem(item *item.Item) *Item {
if item == nil {
return nil
}

return &Item{
Name: item.Name,
DisplayName: item.DisplayName,
Content: item.Content,
CreateTime: item.CreateTime,
UpdateTime: item.UpdateTime,
Properties: item.Properties,
Metadata: item.Metadata,
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/matryer/moq v0.5.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/oapi-codegen/oapi-codegen/v2 v2.3.0
github.com/oapi-codegen/runtime v1.1.1
github.com/pressly/goose/v3 v3.21.1
github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5
Expand Down Expand Up @@ -39,7 +40,6 @@ require (
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oapi-codegen/runtime v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
Expand Down
18 changes: 9 additions & 9 deletions item/item.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ const ItemResource = "item"
// Item is the core data structure for the content management system.
// An item represent a single piece of content. It is the structured version of a markdown file.
type Item struct {
Name string `mapstructure:"name"`
DisplayName string `mapstructure:"display_name"`
Content string `mapstructure:"content"`
Hash string `mapstructure:"hash"`
CreateTime time.Time `mapstructure:"create_time"`
UpdateTime time.Time `mapstructure:"update_time"`
DeleteTime *time.Time `mapstructure:"delete_time"`
Properties map[string]any `mapstructure:"properties"`
Metadata map[string]any `mapstructure:"metadata"`
Name string
DisplayName string
Content string
Hash string
CreateTime time.Time
UpdateTime time.Time
DeleteTime *time.Time
Properties map[string]any
Metadata map[string]any
}
6 changes: 2 additions & 4 deletions item/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type Repository interface {

// TODO: Add transaction to all methods.
// TODO: Add method to get a transaction.
GetItem(ctx context.Context, uid string) (*Item, error)
GetItem(ctx context.Context, name string) (*Item, error)
UpdateItem(ctx context.Context, item *Item) error
DeleteItem(ctx context.Context, uid string) error
}
Expand Down Expand Up @@ -98,14 +98,12 @@ func (r *repository) CreateItem(ctx context.Context, tx *sql.Tx, item *Item) err
return nil
}

// TODO: Where delete time is null.

// GetItem retrieves an item from the database by its resource name.
func (r *repository) GetItem(ctx context.Context, name string) (*Item, error) {
query := `
SELECT name, display_name, create_time, update_time, delete_time, hash, content, properties, metadata
FROM items
WHERE name = $1
WHERE name = $1 AND delete_time IS NULL
`
var item Item
var propertiesJSON []byte
Expand Down
13 changes: 12 additions & 1 deletion item/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,18 @@ import (
"github.com/glass-cms/glasscms/lib/resource"
)

// Service is a service for managing items.
type Service struct {
repo Repository
}

// NewService returns a new instance of Service.
func NewService(repo Repository) *Service {
return &Service{
repo: repo,
}
}

// CreateItem creates a new item.
func (s *Service) CreateItem(ctx context.Context, item *Item) error {
err := s.repo.CreateItem(ctx, nil, item)
if errors.Is(err, database.ErrDuplicatePrimaryKey) {
Expand All @@ -27,3 +28,13 @@ func (s *Service) CreateItem(ctx context.Context, item *Item) error {

return err
}

// GetItem retrieves an item by name.
func (s *Service) GetItem(ctx context.Context, name string) (*Item, error) {
item, err := s.repo.GetItem(ctx, name)
if errors.Is(err, database.ErrNotFound) {
return nil, resource.NewNotFoundError(name, ItemResource, err)
}

return item, err
}
35 changes: 35 additions & 0 deletions lib/middleware/accept.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package middleware

import (
"net/http"
"slices"

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

// Accept generates a handler that writes a 415 Unsupported Media Type header
// if the request's Accept header does not match the provided media type.
func Accept(accepted ...string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "" {
// Set default media type of Accept header to application/json
r.Header.Set("Accept", "application/json")
}

header := r.Header.Get("Accept")
mdt, err := mediatype.Parse(header)
if err != nil {
http.Error(w, "Invalid media type for accept header", http.StatusBadRequest)
return
}

if !slices.Contains(accepted, mdt.MediaType) {
http.Error(w, "Unsupported Accept Media Type", http.StatusNotAcceptable)
return
}

next.ServeHTTP(w, r)
})
}
}
73 changes: 73 additions & 0 deletions lib/middleware/accept_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package middleware_test

import (
"net/http"
"net/http/httptest"
"testing"

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

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

tests := map[string]struct {
acceptHeader string
accepted []string
expected int
}{
"application/json": {
acceptHeader: "application/json",
accepted: []string{"application/json"},
expected: http.StatusOK,
},
"application/json with charset": {
acceptHeader: "application/json; charset=utf-8",
accepted: []string{"application/json"},
expected: http.StatusOK,
},
"application/xml": {
acceptHeader: "application/xml",
accepted: []string{"application/xml"},
expected: http.StatusOK,
},
"unsupported": {
acceptHeader: "text/plain",
accepted: []string{"application/json"},
expected: http.StatusNotAcceptable,
},
"invalid": {
acceptHeader: "text/",
accepted: []string{"application/json"},
expected: http.StatusBadRequest,
},
"empty": {
acceptHeader: "",
accepted: []string{"application/json"},
expected: http.StatusOK,
},
}

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

req, err := http.NewRequest(http.MethodGet, "/test", nil)
if err != nil {
t.Fatal(err)
}

req.Header.Set("Accept", test.acceptHeader)

rr := httptest.NewRecorder()

middleware.Accept(test.accepted...)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})).ServeHTTP(rr, req)

if rr.Code != test.expected {
t.Errorf("expected %d; got %d", test.expected, rr.Code)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import (
"github.com/glass-cms/glasscms/lib/mediatype"
)

// MediaType generates a handler that writes a 415 Unsupported Media Type header
// ContentType generates a handler that writes a 415 Unsupported Media Type header
// if the request's Content-Type header does not match the provided media type.
func MediaType(accepted ...string) func(next http.Handler) http.Handler {
func ContentType(accepted ...string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Content-Type")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/glass-cms/glasscms/lib/middleware"
)

func Test_MediaType(t *testing.T) {
func Test_ContentType(t *testing.T) {
t.Parallel()

tests := map[string]struct {
Expand Down Expand Up @@ -65,7 +65,7 @@ func Test_MediaType(t *testing.T) {
})

w := httptest.NewRecorder()
middleware.MediaType(test.accepted...)(handler).ServeHTTP(w, req)
middleware.ContentType(test.accepted...)(handler).ServeHTTP(w, req)

if w.Code != test.expected {
t.Errorf("expected %d, got %d", test.expected, w.Code)
Expand Down
20 changes: 19 additions & 1 deletion lib/resource/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,29 @@ func (e *AlreadyExistsError) Error() string {
return e.err.Error()
}

// NewAlreadyExistsError creates a new ResourceAlreadyExistsError.
func NewAlreadyExistsError(name, resource string, err error) *AlreadyExistsError {
return &AlreadyExistsError{
Name: name,
Resource: resource,
err: err,
}
}

// NotFoundError represents an error when a resource is not found.
type NotFoundError struct {
Name string
Resource string
err error
}

func (e *NotFoundError) Error() string {
return e.err.Error()
}

func NewNotFoundError(name, resource string, err error) *NotFoundError {
return &NotFoundError{
Name: name,
Resource: resource,
err: err,
}
}
4 changes: 3 additions & 1 deletion lib/test/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ package test

import (
"database/sql"
"fmt"

"github.com/glass-cms/glasscms/database"
"github.com/google/uuid"
)

// NewDB creates a new in-memory SQLite database with the necessary schema
// for testing purposes.
func NewDB() (*sql.DB, error) {
config := database.Config{
Driver: database.DriverName[int32(database.DriverSqlite)],
DSN: "file::memory:?cache=shared",
DSN: fmt.Sprintf("file:%s?mode=memory&cache=shared", uuid.New().String()),
}

db, err := database.NewConnection(config)
Expand Down
18 changes: 0 additions & 18 deletions server/handler/json.go

This file was deleted.

32 changes: 32 additions & 0 deletions server/handler/serialize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package handler

import (
"encoding/json"
"encoding/xml"
"net/http"

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

// SerializeResponse writes the provided data to the response writer in the
// appropriate media type based on the request's Accept header.
func SerializeResponse[T any](w http.ResponseWriter, r *http.Request, statusCode int, data T) {
w.WriteHeader(statusCode)

switch r.Header.Get("Accept") {
case mediatype.ApplicationJSON:
w.Header().Set("Content-Type", mediatype.ApplicationJSON)

if err := json.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "Failed to encode JSON", http.StatusInternalServerError)
}
case mediatype.ApplicationXML:
w.Header().Set("Content-Type", mediatype.ApplicationXML)

if err := xml.NewEncoder(w).Encode(data); err != nil {
http.Error(w, "Failed to encode XML", http.StatusInternalServerError)
}
default:
http.Error(w, "Unsupported media type", http.StatusNotAcceptable)
}
}
13 changes: 10 additions & 3 deletions server/handler/v1/error_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func (h *ErrorHandler) RegisterErrorMapper(errType reflect.Type, mapper ErrorMap
}

// HandleError handles an error by writing an appropriate response to the client.
func (h *ErrorHandler) HandleError(w http.ResponseWriter, _ *http.Request, err error) {
func (h *ErrorHandler) HandleError(w http.ResponseWriter, r *http.Request, err error) {
if err == nil {
return
}
Expand All @@ -42,8 +42,15 @@ func (h *ErrorHandler) HandleError(w http.ResponseWriter, _ *http.Request, err e
statusCode = http.StatusInternalServerError
}

handler.RespondWithJSON(w, statusCode, errResp)
handler.SerializeResponse(w, r, statusCode, errResp)
return
}

// TODO: Default error handling.
// Fallback on generic error response if we don't have a specific error mapper.
errResp := &v1.Error{
Code: v1.ProcessingError,
Message: "An error occurred while processing the request.",
Type: v1.ApiError,
}
handler.SerializeResponse(w, r, http.StatusInternalServerError, errResp)
}
Loading

0 comments on commit 1dd9273

Please sign in to comment.