diff --git a/.gitignore b/.gitignore index 22879ca..b400b13 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ docs/how_to_guides/* # Notes TODO.md -profile.cov \ No newline at end of file +profile.cov + +# Sqlite +*.sqlite \ No newline at end of file diff --git a/api/v1/api.gen.go b/api/v1/api.gen.go index a162e8a..f4b6486 100644 --- a/api/v1/api.gen.go +++ b/api/v1/api.gen.go @@ -15,11 +15,12 @@ import ( type Item struct { Content string `json:"content"` CreateTime time.Time `json:"create_time"` + DeleteTime time.Time `json:"delete_time"` DisplayName string `json:"display_name"` - Id string `json:"id"` + Metadata map[string]interface{} `json:"metadata"` Name string `json:"name"` - Path string `json:"path"` Properties map[string]interface{} `json:"properties"` + Uid string `json:"uid"` UpdateTime time.Time `json:"update_time"` } diff --git a/api/v1/item.go b/api/v1/item.go index ec34804..aca472e 100644 --- a/api/v1/item.go +++ b/api/v1/item.go @@ -10,14 +10,16 @@ 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, + UID: i.Uid, + Name: i.Name, + DisplayName: i.DisplayName, + Content: i.Content, + Hash: parser.HashContent([]byte(i.Content)), + CreateTime: i.CreateTime, + UpdateTime: i.UpdateTime, + Properties: i.Properties, + Metadata: i.Metadata, } } diff --git a/api/v1/item_test.go b/api/v1/item_test.go index fbc0472..6741176 100644 --- a/api/v1/item_test.go +++ b/api/v1/item_test.go @@ -19,7 +19,6 @@ func TestItem_MapToDomain(t *testing.T) { DisplayName string ID string Name string - Path string Properties map[string]interface{} UpdateTime time.Time } @@ -37,19 +36,18 @@ func TestItem_MapToDomain(t *testing.T) { 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"}, + UID: "1234", + Name: "Test Name", + DisplayName: "Test Display Name", + 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"}, }, }, { @@ -60,19 +58,18 @@ func TestItem_MapToDomain(t *testing.T) { 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, + UID: "", + Name: "", + DisplayName: "", + Content: "", + Hash: parser.HashContent([]byte("")), + CreateTime: time.Time{}, + UpdateTime: time.Time{}, + Properties: nil, }, }, { @@ -83,19 +80,18 @@ func TestItem_MapToDomain(t *testing.T) { 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, + UID: "5678", + Name: "Test Name 2", + Content: "Test Content", + DisplayName: "Test Display Name", + Hash: parser.HashContent([]byte("Test Content")), + CreateTime: time.Now().Add(-24 * time.Hour), + UpdateTime: time.Now(), + Properties: nil, }, }, } @@ -108,9 +104,8 @@ func TestItem_MapToDomain(t *testing.T) { Content: tt.fields.Content, CreateTime: tt.fields.CreateTime, DisplayName: tt.fields.DisplayName, - Id: tt.fields.ID, + Uid: tt.fields.ID, Name: tt.fields.Name, - Path: tt.fields.Path, Properties: tt.fields.Properties, UpdateTime: tt.fields.UpdateTime, } diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 0000000..b01d7bf --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "log/slog" + "os" + + "github.com/glass-cms/glasscms/database" + "github.com/lmittmann/tint" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +type MigrateCommand struct { + Command *cobra.Command + logger *slog.Logger + + databaseConfig database.Config +} + +func NewMigrateCommand() *MigrateCommand { + mc := &MigrateCommand{ + logger: slog.New( + tint.NewHandler(os.Stdout, &tint.Options{ + Level: slog.LevelDebug, + }), + ), + } + + mc.Command = &cobra.Command{ + Use: "migrate", + Short: "Migrate the database schema", + Hidden: true, + RunE: mc.Execute, + Args: cobra.NoArgs, + } + + flagset := mc.Command.Flags() + + flagset.StringVar( + &mc.databaseConfig.Driver, + database.ArgDriver, + "", + "The name of the database driver", + ) + _ = viper.BindPFlag(database.ArgDriver, flagset.Lookup(database.ArgDriver)) + + flagset.StringVar( + &mc.databaseConfig.DSN, + database.ArgDSN, + "", + "The data source name (DSN) for the database", + ) + _ = viper.BindPFlag(database.ArgDSN, flagset.Lookup(database.ArgDSN)) + + return mc +} + +func (mc *MigrateCommand) Execute(_ *cobra.Command, _ []string) error { + mc.logger.Info("Migrating the database schema") + + db, err := database.NewConnection(mc.databaseConfig) + if err != nil { + mc.logger.Error("Failed to create a new database connection") + return err + } + + return database.MigrateDatabase(db, mc.databaseConfig) +} diff --git a/cmd/root.go b/cmd/root.go index b74401a..ec96acf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -39,6 +39,7 @@ func init() { rootCmd.AddCommand(NewConvertCommand().Command) rootCmd.AddCommand(NewDocsCommand().Command) rootCmd.AddCommand(server.NewCommand().Command) + rootCmd.AddCommand(NewMigrateCommand().Command) // Register flags. pflags := rootCmd.PersistentFlags() diff --git a/config.yaml b/config.yaml index 7b1d461..4931703 100644 --- a/config.yaml +++ b/config.yaml @@ -1,5 +1,5 @@ output: './out' database: - dsn: 'file:test.db?cache=shared&mode=memory' + dsn: 'file:test.sqlite?cache=shared' driver: 'sqlite3' \ No newline at end of file diff --git a/database/config.go b/database/config.go index 022528b..3a3ae68 100644 --- a/database/config.go +++ b/database/config.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" + // Import the PostgreSQL driver. + _ "github.com/lib/pq" // Import the SQLite3 driver. _ "github.com/mattn/go-sqlite3" ) diff --git a/database/migrations/1_create_items.sql b/database/migrations/1_create_items.sql index 513fb04..5a9a6fa 100644 --- a/database/migrations/1_create_items.sql +++ b/database/migrations/1_create_items.sql @@ -1,14 +1,19 @@ -- +goose Up CREATE TABLE items ( uid TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, create_time TIMESTAMP NOT NULL, update_time TIMESTAMP NOT NULL, + delete_time TIMESTAMP, hash TEXT, - name TEXT NOT NULL, - path TEXT NOT NULL, content TEXT, - properties JSON + properties JSON, + metadata JSON ); +CREATE INDEX items_name ON items(name); +CREATE INDEX items_delete_time ON items(delete_time); + -- +goose Down DROP TABLE items; \ No newline at end of file diff --git a/go.mod b/go.mod index f96a03f..6c5e454 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.0 require ( github.com/djherbis/times v1.6.0 + github.com/lib/pq v1.10.9 github.com/lmittmann/tint v1.0.4 github.com/mattn/go-sqlite3 v1.14.22 github.com/oapi-codegen/oapi-codegen/v2 v2.3.0 diff --git a/go.sum b/go.sum index 89e9100..d0d6a14 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ 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/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc= github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= diff --git a/item/item.go b/item/item.go index c49bd9f..2fc4c2f 100644 --- a/item/item.go +++ b/item/item.go @@ -2,18 +2,17 @@ package item import "time" -const ( - PropertyTitle = "title" -) - +// 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 { - UID string `json:"uid" yaml:"uid"` - // Name is the full resource name of the item. - Name string `json:"name" yaml:"name"` - Path string `json:"path" yaml:"path"` - Content string `json:"content" yaml:"content"` - Hash string `json:"hash" yaml:"hash"` - CreateTime time.Time `json:"create_time" yaml:"create_time"` - UpdateTime time.Time `json:"update_time" yaml:"update_time"` - Properties map[string]any `json:"properties" yaml:"properties"` + UID string `mapstructure:"uid"` + 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"` } diff --git a/item/repository.go b/item/repository.go index 2c26dc4..e7ea238 100644 --- a/item/repository.go +++ b/item/repository.go @@ -19,15 +19,30 @@ func NewRepository(db *sql.DB) *Repository { // CreateItem creates a new item in the database. func (r *Repository) CreateItem(ctx context.Context, item *Item) error { query := ` - INSERT INTO items (uid, create_time, update_time, hash, name, path, content, properties) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO items ( + uid, + name, + display_name, + create_time, + update_time, + delete_time, + hash, + content, + properties, + metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ` - propertiesJSON, err := json.Marshal(item.Properties) + properties, err := json.Marshal(item.Properties) if err != nil { return fmt.Errorf("failed to marshal properties: %w", err) } + metadata, err := json.Marshal(item.Metadata) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + stmt, err := r.db.PrepareContext(ctx, query) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) @@ -36,13 +51,15 @@ func (r *Repository) CreateItem(ctx context.Context, item *Item) error { _, err = stmt.ExecContext(ctx, item.UID, + item.Name, + item.DisplayName, item.CreateTime, item.UpdateTime, + item.DeleteTime, item.Hash, - item.Name, - item.Path, item.Content, - propertiesJSON, + properties, + metadata, ) if err != nil { @@ -55,12 +72,13 @@ func (r *Repository) CreateItem(ctx context.Context, item *Item) error { // GetItem retrieves an item from the database by its UID. func (r *Repository) GetItem(ctx context.Context, uid string) (*Item, error) { query := ` - SELECT uid, create_time, update_time, hash, name, path, content, properties + SELECT uid, name, display_name, create_time, update_time, delete_time, hash, content, properties, metadata FROM items WHERE uid = $1 ` var item Item var propertiesJSON []byte + var metadataJSON []byte stmt, err := r.db.PrepareContext(ctx, query) if err != nil { @@ -70,13 +88,15 @@ func (r *Repository) GetItem(ctx context.Context, uid string) (*Item, error) { err = stmt.QueryRowContext(ctx, uid).Scan( &item.UID, + &item.Name, + &item.DisplayName, &item.CreateTime, &item.UpdateTime, + &item.DeleteTime, &item.Hash, - &item.Name, - &item.Path, &item.Content, &propertiesJSON, + &metadataJSON, ) if err != nil { @@ -91,6 +111,11 @@ func (r *Repository) GetItem(ctx context.Context, uid string) (*Item, error) { return nil, fmt.Errorf("failed to unmarshal properties: %w", err) } + err = json.Unmarshal(metadataJSON, &item.Metadata) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) + } + return &item, nil } @@ -98,7 +123,7 @@ func (r *Repository) GetItem(ctx context.Context, uid string) (*Item, error) { func (r *Repository) UpdateItem(ctx context.Context, item *Item) error { query := ` UPDATE items - SET update_time = $1, hash = $2, name = $3, path = $4, content = $5, properties = $6 + SET update_time = $1, hash = $2, name = $3, display_name = $4, content = $5, properties = $6, metadata = $7 WHERE uid = $8 ` @@ -107,6 +132,11 @@ func (r *Repository) UpdateItem(ctx context.Context, item *Item) error { return fmt.Errorf("failed to marshal properties: %w", err) } + metadataJSON, err := json.Marshal(item.Metadata) + if err != nil { + return fmt.Errorf("failed to marshal metadata: %w", err) + } + stmt, err := r.db.PrepareContext(ctx, query) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) @@ -117,9 +147,10 @@ func (r *Repository) UpdateItem(ctx context.Context, item *Item) error { item.UpdateTime, item.Hash, item.Name, - item.Path, + item.DisplayName, item.Content, propertiesJSON, + metadataJSON, item.UID, ) @@ -140,8 +171,9 @@ func (r *Repository) UpdateItem(ctx context.Context, item *Item) error { } // DeleteItem deletes an item from the database by its UID. -func (r *Repository) DeleteItem(ctx context.Context, uid string) error { - query := `DELETE FROM items WHERE uid = $1` +func (r *Repository) DeleteItem(_ context.Context, _ string) error { + // TODO: Reimplement with soft delete. + /*query := `DELETE FROM items WHERE uid = $1` stmt, err := r.db.PrepareContext(ctx, query) if err != nil { @@ -161,7 +193,7 @@ func (r *Repository) DeleteItem(ctx context.Context, uid string) error { if rowsAffected == 0 { return errors.New("item not found") - } + }*/ return nil } diff --git a/item/repository_test.go b/item/repository_test.go index 2f05ede..1c4a09a 100644 --- a/item/repository_test.go +++ b/item/repository_test.go @@ -38,14 +38,14 @@ func SeedDatabase(db *sql.DB, items ...*item.Item) error { func getTestItem() *item.Item { return &item.Item{ - UID: "1234", - CreateTime: time.Now(), - UpdateTime: time.Now(), - Hash: "hash", - Name: "Name", - Path: "Path", - Content: "Content", - Properties: map[string]interface{}{"key": "value"}, + UID: "1234", + CreateTime: time.Now(), + UpdateTime: time.Now(), + Hash: "hash", + Name: "items/name", + DisplayName: "DisplayName", + Content: "Content", + Properties: map[string]interface{}{"key": "value"}, } } @@ -181,9 +181,10 @@ func TestRepository_GetItem(t *testing.T) { assert.WithinDuration(t, tt.want.UpdateTime, got.UpdateTime, time.Second) assert.Equal(t, tt.want.Hash, got.Hash) assert.Equal(t, tt.want.Name, got.Name) - assert.Equal(t, tt.want.Path, got.Path) + assert.Equal(t, tt.want.DisplayName, got.DisplayName) assert.Equal(t, tt.want.Content, got.Content) assert.Equal(t, tt.want.Properties, got.Properties) + assert.Equal(t, tt.want.Metadata, got.Metadata) } }) } @@ -217,14 +218,14 @@ func TestRepository_UpdateItem(t *testing.T) { args: args{ ctx: context.Background(), item: &item.Item{ - UID: "1234", - CreateTime: time.Now(), - UpdateTime: time.Now(), - Hash: "newhash", - Name: "NewName", - Path: "NewPath", - Content: "NewContent", - Properties: map[string]interface{}{"newkey": "newvalue"}, + UID: "1234", + CreateTime: time.Now(), + UpdateTime: time.Now(), + Hash: "newhash", + Name: "NewName", + DisplayName: "NewDisplayName", + Content: "NewContent", + Properties: map[string]interface{}{"newkey": "newvalue"}, }, }, wantErr: false, @@ -245,14 +246,14 @@ func TestRepository_UpdateItem(t *testing.T) { return ctx }(), item: &item.Item{ - UID: "1234", - CreateTime: time.Now(), - UpdateTime: time.Now(), - Hash: "newhash", - Name: "NewName", - Path: "NewPath", - Content: "NewContent", - Properties: map[string]interface{}{"newkey": "newvalue"}, + UID: "1234", + CreateTime: time.Now(), + UpdateTime: time.Now(), + Hash: "newhash", + Name: "NewName", + DisplayName: "NewDisplayName", + Content: "NewContent", + Properties: map[string]interface{}{"newkey": "newvalue"}, }, }, wantErr: true, @@ -264,14 +265,14 @@ func TestRepository_UpdateItem(t *testing.T) { args: args{ ctx: context.Background(), item: &item.Item{ - UID: "nonexistent", - CreateTime: time.Now(), - UpdateTime: time.Now(), - Hash: "newhash", - Name: "NewName", - Path: "NewPath", - Content: "NewContent", - Properties: map[string]interface{}{"newkey": "newvalue"}, + UID: "nonexistent", + CreateTime: time.Now(), + UpdateTime: time.Now(), + Hash: "newhash", + Name: "NewName", + DisplayName: "NewDisplayName", + Content: "NewContent", + Properties: map[string]interface{}{"newkey": "newvalue"}, }, }, wantErr: true, @@ -290,78 +291,3 @@ func TestRepository_UpdateItem(t *testing.T) { }) } } - -func TestRepository_DeleteItem(t *testing.T) { - t.Parallel() - - type fields struct { - db *sql.DB - seed func(*sql.DB) - } - type args struct { - ctx context.Context - uid string - } - tests := map[string]struct { - fields fields - args args - wantErr bool - }{ - "Successful deletion": { - fields: fields{ - db: GetTestDatabase(), - seed: func(db *sql.DB) { - if err := SeedDatabase(db, getTestItem()); err != nil { - t.Error(err) - } - }, - }, - args: args{ - ctx: context.Background(), - uid: "1234", - }, - wantErr: false, - }, - "Context canceled": { - fields: fields{ - db: GetTestDatabase(), - seed: func(db *sql.DB) { - if err := SeedDatabase(db, getTestItem()); err != nil { - t.Error(err) - } - }, - }, - args: args{ - ctx: func() context.Context { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - return ctx - }(), - uid: "1234", - }, - wantErr: true, - }, - "Item not found": { - fields: fields{ - db: GetTestDatabase(), - }, - args: args{ - ctx: context.Background(), - uid: "nonexistent", - }, - wantErr: true, - }, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - r := item.NewRepository(tt.fields.db) - if tt.fields.seed != nil { - tt.fields.seed(tt.fields.db) - } - err := r.DeleteItem(tt.args.ctx, tt.args.uid) - assert.Equal(t, tt.wantErr, err != nil, "Repository.DeleteItem() error = %v, wantErr %v", err, tt.wantErr) - }) - } -} diff --git a/lib/log/noop.go b/lib/log/noop.go new file mode 100644 index 0000000..67b2a35 --- /dev/null +++ b/lib/log/noop.go @@ -0,0 +1,21 @@ +package log + +import ( + "context" + "log/slog" +) + +// Noop is a [Handler] which is always disabled and therefore logs nothing. +var Noop slog.Handler = noopHandler{} + +type noopHandler struct{} + +func (noopHandler) Enabled(context.Context, slog.Level) bool { return false } +func (noopHandler) Handle(context.Context, slog.Record) error { return nil } +func (d noopHandler) WithAttrs([]slog.Attr) slog.Handler { return d } +func (d noopHandler) WithGroup(string) slog.Handler { return d } + +// NoopLogger returns a new slog.Logger that logs nothing. +func NoopLogger() *slog.Logger { + return slog.New(Noop) +} diff --git a/lib/test/database.go b/lib/test/database.go new file mode 100644 index 0000000..67786b6 --- /dev/null +++ b/lib/test/database.go @@ -0,0 +1,27 @@ +package test + +import ( + "database/sql" + + "github.com/glass-cms/glasscms/database" +) + +// 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", + } + + db, err := database.NewConnection(config) + if err != nil { + return nil, err + } + + if err = database.MigrateDatabase(db, config); err != nil { + return nil, err + } + + return db, nil +} diff --git a/lib/test/errorreadcloser.go b/lib/test/errorreadcloser.go new file mode 100644 index 0000000..4714efd --- /dev/null +++ b/lib/test/errorreadcloser.go @@ -0,0 +1,16 @@ +package test + +import ( + "errors" +) + +// ErrorReadCloser is a mock io.ReadCloser that always returns an error when reading. +type ErrorReadCloser struct{} + +func (c *ErrorReadCloser) Read(_ []byte) (int, error) { + return 0, errors.New("error reading") +} + +func (c *ErrorReadCloser) Close() error { + return nil +} diff --git a/openapi/openapi.v1.yaml b/openapi/openapi.v1.yaml index 2857522..32469be 100644 --- a/openapi/openapi.v1.yaml +++ b/openapi/openapi.v1.yaml @@ -52,23 +52,22 @@ components: Item: type: object required: - - id + - uid - name - display_name - - path - content - create_time - update_time + - delete_time - properties + - metadata properties: - id: + uid: type: string name: type: string display_name: type: string - path: - type: string content: type: string create_time: @@ -77,9 +76,15 @@ components: update_time: type: string format: date-time + delete_time: + type: string + format: date-time properties: type: object additionalProperties: {} + metadata: + type: object + additionalProperties: {} description: Item represents an individual content item. Versions: type: string diff --git a/parser/parser.go b/parser/parser.go index c72e68f..c81090f 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -44,8 +44,8 @@ func Parse(src sourcer.Source) (*item.Item, error) { } return &item.Item{ - Name: nameFromPath(src.Name()), - Path: src.Name(), + Name: nameFromPath(src.Name()), + //Path: src.Name(), Content: string(content), Hash: HashContent(content), CreateTime: src.CreatedAt(), diff --git a/server/handler/v1/v1.go b/server/handler/v1/handler_v1.go similarity index 100% rename from server/handler/v1/v1.go rename to server/handler/v1/handler_v1.go diff --git a/server/handler/v1/item.go b/server/handler/v1/item.go index 25f1624..6ee8fa8 100644 --- a/server/handler/v1/item.go +++ b/server/handler/v1/item.go @@ -1,6 +1,13 @@ package v1 -import "net/http" +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + v1 "github.com/glass-cms/glasscms/api/v1" +) func (s *APIHandler) ItemsDelete(w http.ResponseWriter, _ *http.Request) { // TODO. @@ -12,6 +19,28 @@ func (s *APIHandler) ItemsList(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotImplemented) } -func (s *APIHandler) ItemsCreate(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotImplemented) +func (s *APIHandler) ItemsCreate(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + reqBody, err := io.ReadAll(r.Body) + if err != nil { + s.logger.ErrorContext(ctx, fmt.Errorf("failed to read request body: %w", err).Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + var request *v1.ItemsCreateJSONRequestBody + if err = json.Unmarshal(reqBody, &request); err != nil { + s.logger.ErrorContext(ctx, fmt.Errorf("failed to unmarshal request body: %w", err).Error()) + w.WriteHeader(http.StatusBadRequest) + return + } + + if err = s.repository.CreateItem(ctx, request.MapToDomain()); err != nil { + s.logger.ErrorContext(ctx, fmt.Errorf("failed to create item: %w", err).Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) } diff --git a/server/handler/v1/item_test.go b/server/handler/v1/item_test.go new file mode 100644 index 0000000..0cd14c1 --- /dev/null +++ b/server/handler/v1/item_test.go @@ -0,0 +1,78 @@ +package v1_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + api "github.com/glass-cms/glasscms/api/v1" + "github.com/glass-cms/glasscms/item" + "github.com/glass-cms/glasscms/lib/log" + "github.com/glass-cms/glasscms/lib/test" + v1 "github.com/glass-cms/glasscms/server/handler/v1" + "github.com/stretchr/testify/assert" +) + +func TestAPIHandler_ItemsCreate(t *testing.T) { + t.Parallel() + + testdb, err := test.NewDB() + if err != nil { + t.Fatal(err) + } + + repo := item.NewRepository(testdb) + + tests := map[string]struct { + req func() *http.Request + expected int + }{ + "returns a 500 status code when the request body cannot be read": { + req: func() *http.Request { + return &http.Request{ + Body: &test.ErrorReadCloser{}, + } + }, + expected: http.StatusInternalServerError, + }, + "returns a 400 status code when the buffer cannot be unmarshalled": { + req: func() *http.Request { + return httptest.NewRequest(http.MethodPost, "/v1/items", nil) + }, + expected: http.StatusBadRequest, + }, + "returns a 201 status code when the item is created successfully": { + req: func() *http.Request { + item := &api.ItemsCreateJSONRequestBody{ + Content: "content", + Name: "name", + } + body, _ := json.Marshal(item) + return httptest.NewRequest(http.MethodPost, "/v1/items", bytes.NewReader(body)) + }, + expected: http.StatusCreated, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + handler := v1.NewAPIHandler( + log.NoopLogger(), + repo, + ) + + rr := httptest.NewRecorder() + request := tt.req() + + // Act + handler.ItemsCreate(rr, request) + + // Assert + assert.Equal(t, tt.expected, rr.Code) + }) + } +} diff --git a/typespec/main.tsp b/typespec/main.tsp index b20b0b2..737e62e 100644 --- a/typespec/main.tsp +++ b/typespec/main.tsp @@ -28,12 +28,13 @@ namespace Items { @doc("Item represents an individual content item.") model Item { - id: string; + uid: string; name: string; display_name: string; - path: string; content: string; create_time: utcDateTime; update_time: utcDateTime; + delete_time: utcDateTime; properties: Record; + metadata: Record; }