diff --git a/README.md b/README.md index 0998a97..76713d6 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ As a learning feature, I don't plan password forgotten features and similar. Jus Regarding the architecture I plan to stick to something relatively simple. However I did not have definitive answers yet. At the moment here are the points I think are gonna be true: -- It is a server/client architecture, opposed to P2P (peer-to-peer) ; -- It is going to use http(s) transport for synchronous communication ; -- It is going to use some Socket-like transport for real-time. + - It is a server/client architecture, opposed to P2P (peer-to-peer) ; + - It is going to use http(s) transport for synchronous communication ; + - It is going to use some Socket-like transport for real-time. To store data I'll start with a relationnal database. Regarding the code organization I think I'll follow some hexagonal architecture principals. @@ -59,6 +59,20 @@ If you're having trouble with permissions, ensure you have the executable right: sudo chmod +x ./gochat ``` +Also you can start a PostgreSQL database container with this docker command: + +```bash +docker run --name gochat-server-db -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres + ``` + + This way you can run the server like so: + + ```bash + DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres?sslmode=disable" ./gochat-server +``` + +Keep in mind this is for local development of course, not production ready. + ## Test Simply run diff --git a/db/migrations/000001_create_users_table.down.sql b/db/migrations/000001_create_users_table.down.sql new file mode 100644 index 0000000..365a210 --- /dev/null +++ b/db/migrations/000001_create_users_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/db/migrations/000001_create_users_table.up.sql b/db/migrations/000001_create_users_table.up.sql new file mode 100644 index 0000000..1f857e8 --- /dev/null +++ b/db/migrations/000001_create_users_table.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE + IF NOT EXISTS users ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); \ No newline at end of file diff --git a/db/migrations/000002_create_rooms_table.down.sql b/db/migrations/000002_create_rooms_table.down.sql new file mode 100644 index 0000000..5f8b282 --- /dev/null +++ b/db/migrations/000002_create_rooms_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS rooms; \ No newline at end of file diff --git a/db/migrations/000002_create_rooms_table.up.sql b/db/migrations/000002_create_rooms_table.up.sql new file mode 100644 index 0000000..c10c979 --- /dev/null +++ b/db/migrations/000002_create_rooms_table.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE + IF NOT EXISTS rooms ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); diff --git a/db/migrations/000003_create_messages_table.down.sql b/db/migrations/000003_create_messages_table.down.sql new file mode 100644 index 0000000..36f514b --- /dev/null +++ b/db/migrations/000003_create_messages_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS messages; \ No newline at end of file diff --git a/db/migrations/000003_create_messages_table.up.sql b/db/migrations/000003_create_messages_table.up.sql new file mode 100644 index 0000000..d73ecfd --- /dev/null +++ b/db/migrations/000003_create_messages_table.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE + IF NOT EXISTS messages ( + id UUID PRIMARY KEY, + room_id UUID NOT NULL REFERENCES rooms (id), + author_id UUID NOT NULL REFERENCES users (id), + content VARCHAR(2000) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ); diff --git a/go.mod b/go.mod index a58446a..dcbda7b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,20 @@ module alexandre-gerault.fr/gochat-server go 1.24.2 + +require ( + github.com/fufuok/random v0.0.1 + github.com/golang-migrate/migrate/v4 v4.18.3 + github.com/google/uuid v1.6.0 + github.com/lib/pq v1.10.9 + github.com/stretchr/testify v1.10.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.uber.org/atomic v1.7.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..479db27 --- /dev/null +++ b/go.sum @@ -0,0 +1,72 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= +github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fufuok/random v0.0.1 h1:glLBs5Y8PNlsnWGaUhkiVyuNgOMoatHRpnn3DRjyFh8= +github.com/fufuok/random v0.0.1/go.mod h1:E5tRpJw7fsdE+b8GaJFxAvRMMTraJnMmvJiCWCmRAug= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= +github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/messaging/application/send_message.go b/internal/messaging/application/send_message.go new file mode 100644 index 0000000..7f1f114 --- /dev/null +++ b/internal/messaging/application/send_message.go @@ -0,0 +1,67 @@ +package application + +import ( + "alexandre-gerault.fr/gochat-server/internal/messaging/domain" + "github.com/google/uuid" +) + +type SendMessageDto struct { + Author_id string + Room_id string + Content string +} + +type SendMessagePresenter interface { + MessageSentSuccessfully() + MessageEmpty() + TooLongMessage() + AuthorNotFound() + InvalidPayload() + UnexpectedError(error string) +} + +type UuidProvider interface { + Generate() (uuid.UUID, error) +} + +func SendMessageHandler( + author_repository domain.AuthorRepository, + message_repository domain.MessageRepository, + uuid_provider UuidProvider, +) func(dto SendMessageDto, presenter SendMessagePresenter) { + return func(dto SendMessageDto, presenter SendMessagePresenter) { + author_id, author_err := uuid.Parse(dto.Author_id) + room_id, room_err := uuid.Parse(dto.Room_id) + + if author_err != nil || room_err != nil { + presenter.InvalidPayload() + return + } + + if len(dto.Content) == 0 { + presenter.MessageEmpty() + return + } + + if len(dto.Content) > 2000 { + presenter.TooLongMessage() + return + } + + if !author_repository.Exist(author_id) { + presenter.AuthorNotFound() + return + } + + message_id, err := uuid_provider.Generate() + + if err != nil { + presenter.UnexpectedError(err.Error()) + } + + message := domain.NewMessage(message_id, room_id, author_id, dto.Content) + message_repository.Save(message) + + presenter.MessageSentSuccessfully() + } +} diff --git a/internal/messaging/application/send_message_test.go b/internal/messaging/application/send_message_test.go new file mode 100644 index 0000000..63c253c --- /dev/null +++ b/internal/messaging/application/send_message_test.go @@ -0,0 +1,169 @@ +package application + +import ( + "testing" + + "alexandre-gerault.fr/gochat-server/internal/messaging/domain" + testUtils "alexandre-gerault.fr/gochat-server/internal/testing" + "github.com/fufuok/random" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +type SendMessageTestPresenter struct { + response string +} + +type InMemoryMessageRepository struct { + messages []domain.Message +} + +type InMemoryAuthorRepository struct { + authors []domain.Author +} + +func (in_memory_author_repository *InMemoryAuthorRepository) Exist(id uuid.UUID) bool { + for _, author := range in_memory_author_repository.authors { + if author.Uuid.String() == id.String() { + return true + } + } + + return false +} + +func (in_memory_message_repository *InMemoryMessageRepository) Save(message domain.Message) (uuid.UUID, error) { + in_memory_message_repository.messages = append(in_memory_message_repository.messages, message) + + return message.Id, nil +} + +func (presenter *SendMessageTestPresenter) MessageSentSuccessfully() { + presenter.response = "success" +} + +func (presenter *SendMessageTestPresenter) MessageEmpty() { + presenter.response = "empty" +} + +func (presenter *SendMessageTestPresenter) TooLongMessage() { + presenter.response = "too_long" +} + +func (presenter *SendMessageTestPresenter) AuthorNotFound() { + presenter.response = "author_not_found" +} + +func (presenter *SendMessageTestPresenter) InvalidPayload() { + presenter.response = "invalid_payload" +} + +func (presenter *SendMessageTestPresenter) UnexpectedError(error string) { + presenter.response = error +} + +func TestItCanSendMessage(t *testing.T) { + fake_uuid_provider := testUtils.FakeUuidProvider{} + + author_id, author_err := uuid.Parse("01968e00-1b4d-7a91-bb2a-c55bd56a2dac") + room_id, room_err := uuid.Parse("01969529-0a14-7556-a125-e9224be7b3ab") + + if author_err != nil || room_err != nil { + t.Error("Error while parsing a uuid") + } + + dto := SendMessageDto{author_id.String(), room_id.String(), "Some message"} + + message_repository := InMemoryMessageRepository{} + authors_repository := InMemoryAuthorRepository{ + []domain.Author{domain.NewAuthor(author_id)}, + } + + presenter := SendMessageTestPresenter{} + + handler := SendMessageHandler(&authors_repository, &message_repository, &fake_uuid_provider) + + handler(dto, &presenter) + + assert.Equal(t, "success", presenter.response) + assert.Equal(t, 1, len(message_repository.messages)) +} + +func TestItCannotSendAnEmptyMessage(t *testing.T) { + fake_uuid_provider := testUtils.FakeUuidProvider{} + + author_id, author_err := uuid.Parse("01968e00-1b4d-7a91-bb2a-c55bd56a2dac") + room_id, room_err := uuid.Parse("01969529-0a14-7556-a125-e9224be7b3ab") + + if author_err != nil || room_err != nil { + t.Error("Error while parsing a uuid") + } + + dto := SendMessageDto{author_id.String(), room_id.String(), ""} + + message_repository := InMemoryMessageRepository{} + authors_repository := InMemoryAuthorRepository{ + []domain.Author{domain.NewAuthor(author_id)}, + } + + presenter := SendMessageTestPresenter{} + + handler := SendMessageHandler(&authors_repository, &message_repository, &fake_uuid_provider) + + handler(dto, &presenter) + + assert.Equal(t, "empty", presenter.response) + assert.Equal(t, 0, len(message_repository.messages)) +} + +func TestItCannotSendAnOversizedMessage(t *testing.T) { + fake_uuid_provider := testUtils.FakeUuidProvider{} + + author_id, auth_err := uuid.Parse("01968e00-1b4d-7a91-bb2a-c55bd56a2dac") + room_id, room_err := uuid.Parse("01969529-0a14-7556-a125-e9224be7b3ab") + + if auth_err != nil || room_err != nil { + t.Error("Cannot generate author_id") + } + + dto := SendMessageDto{author_id.String(), room_id.String(), random.RandString(2001)} + + message_repository := InMemoryMessageRepository{} + authors_repository := InMemoryAuthorRepository{ + []domain.Author{domain.NewAuthor(author_id)}, + } + + presenter := SendMessageTestPresenter{} + + handler := SendMessageHandler(&authors_repository, &message_repository, &fake_uuid_provider) + + handler(dto, &presenter) + + assert.Equal(t, "too_long", presenter.response) + assert.Equal(t, 0, len(message_repository.messages)) +} + +func TestItCannotSendMessageIfAuthorDoesNotExist(t *testing.T) { + fake_uuid_provider := testUtils.FakeUuidProvider{} + + author_id, auth_err := uuid.Parse("01968e00-1b4d-7a91-bb2a-c55bd56a2dac") + room_id, room_err := uuid.Parse("01969529-0a14-7556-a125-e9224be7b3ab") + + if auth_err != nil || room_err != nil { + t.Error("Cannot generate author_id") + } + + dto := SendMessageDto{author_id.String(), room_id.String(), "Some legal content"} + + message_repository := InMemoryMessageRepository{} + authors_repository := InMemoryAuthorRepository{} + + presenter := SendMessageTestPresenter{} + + handler := SendMessageHandler(&authors_repository, &message_repository, &fake_uuid_provider) + + handler(dto, &presenter) + + assert.Equal(t, "author_not_found", presenter.response) + assert.Equal(t, 0, len(message_repository.messages)) +} diff --git a/internal/messaging/domain/author.go b/internal/messaging/domain/author.go new file mode 100644 index 0000000..971ce68 --- /dev/null +++ b/internal/messaging/domain/author.go @@ -0,0 +1,17 @@ +package domain + +import "github.com/google/uuid" + +type Author struct { + Uuid uuid.UUID +} + +func NewAuthor(uuid uuid.UUID) Author { + return Author{ + uuid, + } +} + +type AuthorRepository interface { + Exist(id uuid.UUID) bool +} diff --git a/internal/messaging/domain/message.go b/internal/messaging/domain/message.go new file mode 100644 index 0000000..62b1868 --- /dev/null +++ b/internal/messaging/domain/message.go @@ -0,0 +1,18 @@ +package domain + +import "github.com/google/uuid" + +type Message struct { + Id uuid.UUID + Room_Id uuid.UUID + Author_Id uuid.UUID + Content string +} + +func NewMessage(message_id uuid.UUID, room_id uuid.UUID, author_id uuid.UUID, content string) Message { + return Message{message_id, room_id, author_id, content} +} + +type MessageRepository interface { + Save(message Message) (uuid.UUID, error) +} diff --git a/internal/messaging/infrastructure/sql_author_repository.go b/internal/messaging/infrastructure/sql_author_repository.go new file mode 100644 index 0000000..c145ec2 --- /dev/null +++ b/internal/messaging/infrastructure/sql_author_repository.go @@ -0,0 +1,24 @@ +package infrastructure + +import ( + "database/sql" + + "alexandre-gerault.fr/gochat-server/internal/messaging/domain" + "github.com/google/uuid" +) + +type SqlAuthorRepository struct { + Database *sql.DB +} + +func (sql_author_repository *SqlAuthorRepository) Exist(id uuid.UUID) bool { + row := sql_author_repository.Database.QueryRow("SELECT id FROM users WHERE id = $1", id.String()) + + var author domain.Author + + if err := row.Scan(&author.Uuid); err != nil { + return false + } + + return true +} diff --git a/internal/messaging/infrastructure/sql_message_repository.go b/internal/messaging/infrastructure/sql_message_repository.go new file mode 100644 index 0000000..d091043 --- /dev/null +++ b/internal/messaging/infrastructure/sql_message_repository.go @@ -0,0 +1,32 @@ +package infrastructure + +import ( + "database/sql" + + "alexandre-gerault.fr/gochat-server/internal/messaging/domain" + "github.com/google/uuid" +) + +type SqlMessageRepository struct { + Database *sql.DB +} + +func (sql_message_repository SqlMessageRepository) Save(message domain.Message) (uuid.UUID, error) { + query := ` + INSERT INTO messages (id, room_id, author_id, content) + VALUES ($1, $2, $3, $4) + ` + _, err := sql_message_repository.Database.Exec( + query, + message.Id, + message.Room_Id, + message.Author_Id, + message.Content, + ) + + if err != nil { + return uuid.Nil, err + } + + return message.Id, nil +} diff --git a/internal/messaging/ui/http/send_message_endpoint.go b/internal/messaging/ui/http/send_message_endpoint.go new file mode 100644 index 0000000..c32b2a8 --- /dev/null +++ b/internal/messaging/ui/http/send_message_endpoint.go @@ -0,0 +1,60 @@ +package http + +import ( + "fmt" + "io" + "net/http" + + "alexandre-gerault.fr/gochat-server/internal/messaging/application" + shared_infrastructure "alexandre-gerault.fr/gochat-server/internal/shared/infrastructure" +) + +type SendMessagePresenter struct { + writer http.ResponseWriter +} + +func (p *SendMessagePresenter) AuthorNotFound() { + p.writer.WriteHeader(http.StatusNotFound) +} + +func (p *SendMessagePresenter) MessageEmpty() { + p.writer.WriteHeader(http.StatusBadRequest) +} + +func (p *SendMessagePresenter) TooLongMessage() { + p.writer.WriteHeader(http.StatusBadRequest) +} + +func (p *SendMessagePresenter) InvalidPayload() { + p.writer.WriteHeader(http.StatusBadRequest) +} + +func (p *SendMessagePresenter) UnexpectedError(error string) { + p.writer.WriteHeader(http.StatusInternalServerError) + io.WriteString(p.writer, fmt.Sprintf("{\"message\": \"%s\"}", error)) +} + +func (p *SendMessagePresenter) MessageSentSuccessfully() { + p.writer.WriteHeader(http.StatusCreated) +} + +func NewSendMessageEndpoint(app *shared_infrastructure.Application) func(writer http.ResponseWriter, request *http.Request) { + return func(writer http.ResponseWriter, request *http.Request) { + handler := application.SendMessageHandler( + app.Dependencies.Author_Repository, + app.Dependencies.Message_Repository, + app.Dependencies.Uuid_Provider, + ) + + presenter := &SendMessagePresenter{writer} + + handler( + application.SendMessageDto{ + Author_id: request.FormValue("author_id"), + Room_id: request.FormValue("room_id"), + Content: request.FormValue("content"), + }, + presenter, + ) + } +} diff --git a/internal/shared/infrastructure/application.go b/internal/shared/infrastructure/application.go new file mode 100644 index 0000000..ade5e90 --- /dev/null +++ b/internal/shared/infrastructure/application.go @@ -0,0 +1,29 @@ +package shared_infrastructure + +import ( + "database/sql" + + "alexandre-gerault.fr/gochat-server/internal/messaging/application" + "alexandre-gerault.fr/gochat-server/internal/messaging/domain" + "alexandre-gerault.fr/gochat-server/internal/messaging/infrastructure" +) + +type Application struct { + Database *sql.DB + Dependencies Dependencies +} + +type Dependencies struct { + Author_Repository domain.AuthorRepository + Message_Repository domain.MessageRepository + Uuid_Provider application.UuidProvider +} + +func (app *Application) Register() *Application { + app.Database = CreateDatabase() + app.Dependencies.Message_Repository = &infrastructure.SqlMessageRepository{Database: app.Database} + app.Dependencies.Author_Repository = &infrastructure.SqlAuthorRepository{Database: app.Database} + app.Dependencies.Uuid_Provider = &UuidGenerator{} + + return app +} diff --git a/internal/shared/infrastructure/database.go b/internal/shared/infrastructure/database.go new file mode 100644 index 0000000..7778871 --- /dev/null +++ b/internal/shared/infrastructure/database.go @@ -0,0 +1,51 @@ +package shared_infrastructure + +import ( + "database/sql" + "fmt" + "log" + "os" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + + _ "github.com/lib/pq" +) + +func RunMigrations() { + migrator, err := migrate.New( + "file://db/migrations", + os.Getenv("DATABASE_URL"), + ) + + if err != nil { + log.Fatal(fmt.Sprintf("Cannot load migrate: %s", err.Error())) + } + + log.Println("Running migrations...") + + up_err := migrator.Up() + + if up_err != nil { + if up_err == migrate.ErrNoChange { + log.Println("No migrations to apply.") + } else { + log.Fatalf("Migration failed: %v", up_err) + } + } else { + log.Println("Migrations ran successfully") + } +} + +func CreateDatabase() *sql.DB { + connection_string := os.Getenv("DATABASE_URL") + + database, err := sql.Open("postgres", connection_string) + + if err != nil { + log.Fatal(err) + } + + return database +} diff --git a/internal/shared/infrastructure/uuid.go b/internal/shared/infrastructure/uuid.go new file mode 100644 index 0000000..8b0d6a8 --- /dev/null +++ b/internal/shared/infrastructure/uuid.go @@ -0,0 +1,13 @@ +package shared_infrastructure + +import ( + "github.com/google/uuid" +) + +type UuidGenerator struct{} + +func (uuid_generator *UuidGenerator) Generate() (uuid.UUID, error) { + uuid, err := uuid.NewV7() + + return uuid, err +} diff --git a/internal/testing/uuid.go b/internal/testing/uuid.go new file mode 100644 index 0000000..2e5b606 --- /dev/null +++ b/internal/testing/uuid.go @@ -0,0 +1,15 @@ +package testing + +import "github.com/google/uuid" + +type FakeUuidProvider struct { + uuidToReturn uuid.UUID +} + +func (provider *FakeUuidProvider) Generate() (uuid.UUID, error) { + return provider.uuidToReturn, nil +} + +func (provider *FakeUuidProvider) ChangeNextUuid(nextUuid uuid.UUID) { + provider.uuidToReturn = nextUuid +} diff --git a/main.go b/main.go index 70a7fe5..25defde 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,35 @@ package main -import "fmt" +import ( + "log" + "net/http" + "time" + + messaging_http "alexandre-gerault.fr/gochat-server/internal/messaging/ui/http" + shared_infrastructure "alexandre-gerault.fr/gochat-server/internal/shared/infrastructure" +) func main() { - fmt.Println("Hello world!") + app := shared_infrastructure.Application{} + + app.Register() + shared_infrastructure.RunMigrations() + + log.Println("Start http server (http://localhost:8080)...") + + router := http.NewServeMux() + + router.HandleFunc("POST /messages/", messaging_http.NewSendMessageEndpoint(&app)) + + httpServer := &http.Server{ + Addr: ":8080", + Handler: router, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + log.Fatal(httpServer.ListenAndServe()) + + log.Println("Gracefully shutdown.") } diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..e88386a --- /dev/null +++ b/start.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env +DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres?sslmode=disable" ./gochat-server