diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..22d0d82 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +vendor diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..ff05a0e --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,18 @@ +name: build + +on: [ push ] + +jobs: + stagedbuild: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: stagedbuild + run: make stagedbuild + + localbuild: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: localbuild + run: make localbuild diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml new file mode 100644 index 0000000..3ca2291 --- /dev/null +++ b/.github/workflows/coverage.yaml @@ -0,0 +1,11 @@ +name: coverage + +on: [ push ] + +jobs: + cover: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - name: unit tests, with coverage + run: make checkcoverage diff --git a/.gitignore b/.gitignore index 66fd13c..4b342d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,9 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib +# Outputs, be they from test or from "make compile" +*.out -# Test binary, built with `go test -c` -*.test +# Dependency directories +vendor/ -# Output of the go coverage tool, specifically when used with LiteIDE -*.out +# IDE files +.idea/* -# Dependency directories (remove the comment below to include it) -# vendor/ diff --git a/CHANGEBLOG.md b/CHANGEBLOG.md new file mode 100644 index 0000000..869e8c0 --- /dev/null +++ b/CHANGEBLOG.md @@ -0,0 +1,20 @@ +# CHANGEBLOG + +This is a personal project, with no intent whatsoever on making any money out of it. The aim is to play +with [Fiber](https://gofiber.io/), as you've probably already guessed after checking the commit messages. + +Why Fiber, you ask, and not something else? Well, I can't / won't write a microservice with several of them, so I had to pick. +Fiber I hadn't worked with hitherto. The first major bump I had was discovering Fiber v2 existed, after +having written all my code. Well, that might be ~100 lines, but still. Updating the dependencies isn't always fun. + +Then I added the logger, and tried to play with `runtime.Callers`. Fun thing, depending on the `build` command, the logs +won't be the same - local path for a `make localbuild`, and something in `/go/src/github.com/floppyzedolfin/square/..` +for the `stagedbuild` command. I scraped that in the end, in order to only retrieve the calling function's name. + +Oh, one fun thing I learnt as I was building my staged Dockerfile: building go without specifying `CGO_ENABLED=0` will +somehow leave C dependencies in the produced binary, when using the `net` package - which, obviously, is what I was +doing. +[This answer](https://stackoverflow.com/a/36308464/2106703) saved me countless hours of browsing the internet wondering +why running my microservice on centos would work, but it wouldn't on alpine or scratch. + +For the sake of coverage, I've added an error case: when requesting the square of `0`, an error is returned. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ee0c2b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Section 1 - build the binary +FROM golang:1.15 as builder + +WORKDIR /go/src/github.com/floppyzedolfin/square +COPY . /go/src/github.com/floppyzedolfin/square +# notice the .dockerignore prevented us from copying the vendors into the image +RUN make compile +RUN chmod +x /go/src/github.com/floppyzedolfin/square/build/square.out + +# Section 2 - build the final image. All we need is the compiled binary +FROM scratch +COPY --from=builder /go/src/github.com/floppyzedolfin/square/build/square.out /app/square.out + +ENTRYPOINT ["/app/square.out"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..98067df --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +NAME=square +VERSION=0.1 +QUALITY_THRESHOLD=80 + +clean: + rm -f build/square.out + rm -f coverage.out + go clean -cache -testcache + docker rmi -f ${NAME}:${VERSION} || true + +compile: + CGO_ENABLED=0 go build -o build/square.out cmd/main.go + +localbuild: compile + docker build -t ${NAME}:${VERSION} build + +run: + docker stop ${NAME} || true + docker run --rm -p 8530:3000 --name ${NAME} ${NAME}:${VERSION} + +stagedbuild: + docker build -t ${NAME}:${VERSION} . + +test: + go test ./... + +checkcoverage: + go test ./... -coverprofile=coverage.out + go tool cover -func coverage.out | awk -F'\t' -v threshold=${QUALITY_THRESHOLD} '/^total:/{print $$0; overall_percent=$$NF; if (overall_percent >= threshold) {exit 0} else {exit 1}}' diff --git a/README.md b/README.md index e0ed4cd..40bff9f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,74 @@ +![](https://github.com/floppyzedolfin/square/workflows/build/badge.svg) ![](https://github.com/floppyzedolfin/square/workflows/coverage/badge.svg) + # square -Playing with Fiber + +Playing with Fiber v2 + +This project was aimed at playing with Fiber. It builds a microservice, inside a Docker image, that exposes an endpoint +that squares an integer. + +## Clone the repository + +```bash +git clone https://github.com/floppyzedolfin/square.git +``` + +## Make targets + +There are two options to build the image. Both options will generate the docker image, and should generate the same +docker image. Read below for potential differences. + +### Compilation + +- `make compile` compiles the service's binary locally. This includes GOPATH values, overwritten commands - such as `go` + , etc. It's useful for a dev point of view, as it provides an on-the-fly mechanism to build the current state of dev + +### Docker image build + +- `make localbuild` generates the image based on the deployed vendors, and on everything your environment has. +- `make stagedbuild` is a more generic build, as it happens inside a pristine environment. The overhead of this is that + we'll need to download the dependencies each time. This is the command to be used in CI for deliveries. + +### Tests and coverage + +- `make test` runs all the Unit Tests. +- `make checkcoverage` runs all the unit tests and ensures the coverage is at least 80%. + +### Execution + +- `make run` launches the service, exposing its port 3000 on the localhost's port 8530. The docker container will be + removed when stopped. + +### Cleanup + +- `make clean` removes local build and test caches, and removes the docker image + +## Playing with it + +Once you've ran `make run`, the service is up and running and you can shoot requests : + +```bash +> curl -X POST -H "content-type:application/json" localhost:8530/square -d '{"value":4}' +{"value":16} +> +``` + +An error scenario has been built-in, for testing an example purposes, if the input value is `0`. + +## Limitations + +- So far, I haven't found a json parser that will cause invalid fields to raise an error. In our example here, the + following request is valid: + +```json +{ + "value": -4, + "foo": "bar" +} +``` + +## TODOs + +- Refactor the `internal/*` files, I'm not sure I like the way they are +- Currently, the `square/build/Dockerfile` is an excerpt of the `square/Dockerfile`. I'd rather it wouldn't. + diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..f3f934b --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,5 @@ +FROM scratch + +COPY square.out /app/square.out + +ENTRYPOINT ["/app/square.out"] diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..68c7972 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "github.com/floppyzedolfin/square/internal/server" + "github.com/floppyzedolfin/square/pkg/logger" +) + +func main() { + // Create the server that will have all the necessary endpoints + s := server.NewServer() + + const port = ":3000" + logger.Log(logger.Info, "starting server on port %s", port) + s.Listen(port) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6198309 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/floppyzedolfin/square + +go 1.15 + +require ( + github.com/gofiber/fiber/v2 v2.5.0 + github.com/jinzhu/gorm v1.9.16 + github.com/stretchr/testify v1.7.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..30b1e55 --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4= +github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= +github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= +github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/gofiber/fiber/v2 v2.5.0 h1:yml405Um7b98EeMjx63OjSFTATLmX985HPWFfNUPV0w= +github.com/gofiber/fiber/v2 v2.5.0/go.mod h1:f8BRRIMjMdRyt2qmJ/0Sea3j3rwwfufPrh9WNBRiVZ0= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= +github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= +github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.0.1 h1:HjfetcXq097iXP0uoPCdnM4Efp5/9MsM0/M+XOTeR3M= +github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/klauspost/compress v1.10.7 h1:7rix8v8GpI3ZBb0nSozFRgbtXKv+hOe+qfEpZqybrAg= +github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= +github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= +github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/mattn/go-sqlite3 v1.14.0 h1:mLyGNKR8+Vv9CAU7PphKa2hkEqxxhn8i32J6FPj1/QA= +github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= +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.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.18.0 h1:IV0DdMlatq9QO1Cr6wGJPVW1sV1Q8HvZXAIcjorylyM= +github.com/valyala/fasthttp v1.18.0/go.mod h1:jjraHZVbKOXftJfsOYoAjaeygpj5hr8ermTRJNroD7A= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a h1:0R4NLDRDZX6JcmhJgXi5E4b8Wg84ihbmUKp/GvSPEzc= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM= +golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201016165138-7b1cca2348c0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201210223839-7e3030f88018 h1:XKi8B/gRBuTZN1vU9gFsLMm6zVz5FSCDzm8JYACnjy8= +golang.org/x/sys v0.0.0-20201210223839-7e3030f88018/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +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.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..f277169 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,28 @@ +package server + +import ( + "github.com/gofiber/fiber/v2" +) + +// Server is the microservice application +type Server struct { + app *fiber.App +} + +// NewServer returns a fully operational server, ready to Listen() +func NewServer() *Server { + s := Server{app: fiber.New()} + s.registerEndpoints() + return &s +} + +// Listen starts the server on the associated port (which is a string starting with ':') +func (s *Server) Listen(port string) { + s.app.Listen(port) +} + +// registerEndpoints adds all necessary endpoints to the server +// add each endpoint here +func (s *Server) registerEndpoints() { + s.registerSquare() +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..37f753a --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,101 @@ +package server + +import ( + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Server(t *testing.T) { + tt := map[string]struct { + verb string + path string + body string + contentType string + errMsg string + returnCode int + response string + }{ + "nominal, on existing endpoint": { + verb: http.MethodPost, + path: "/square", + body: "{\"value\":2}", + contentType: "application/json", + returnCode: fiber.StatusOK, + response: "{\"value\":4}", + }, + "endpoint is ok, but wrong body": { + verb: http.MethodPost, + path: "/square", + body: "{\"value\":\"a\"}", + contentType: "application/json", + returnCode: fiber.StatusBadRequest, + errMsg: "{\"error\":\"unable to parse body as request", // and stuff that are not part of this code + }, + "request is not valid": { + verb: http.MethodPost, + path: "/square", + body: "{}", + contentType: "application/json", + returnCode: fiber.StatusBadRequest, + errMsg: "{\"error\":\"unset request", + }, + "wrong type for the field": { + verb: http.MethodPost, + path: "/square", + body: "{\"value\":3.18}", + contentType: "application/json", + returnCode: fiber.StatusBadRequest, + errMsg: "{\"error\":\"unable to parse body as request", // and stuff that are not part of this code + }, + "error in endpoint": { + verb: http.MethodPost, + path: "/square", + body: "{\"value\":0}", + contentType: "application/json", + returnCode: fiber.StatusNotAcceptable, // I'm not sure I should be covering this - it's hidden within another package + errMsg: "{\"error\":\"", // and stuff that are not part of this code + }, + "wrong verb": { + verb: http.MethodGet, + path: "/square", + returnCode: fiber.StatusMethodNotAllowed, + errMsg: "Method Not Allowed", + }, + "non-existing endpoint": { + verb: http.MethodGet, + path: "/cube", + returnCode: fiber.StatusNotFound, + errMsg: "Cannot GET /cube", + }, + } + + s := NewServer() + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + req, err := http.NewRequest(tc.verb, tc.path, strings.NewReader(tc.body)) + require.NoError(t, err) + req.Header.Add("content-type", tc.contentType) + + res, err := s.app.Test(req) + assert.Equal(t, tc.returnCode, res.StatusCode) + assert.NoError(t, err) // errors are handled inside the endpoint + if tc.errMsg != "" { + body, err := ioutil.ReadAll(res.Body) + require.NoError(t, err) + // we don't want to rely on what the inner libs do - a mere check that the error contains what is written in the endpoint is enough + assert.Contains(t, string(body), tc.errMsg) + } else { + body, err := ioutil.ReadAll(res.Body) + require.NoError(t, err) + assert.Equal(t, tc.response, string(body)) + } + }) + } +} diff --git a/internal/server/square.go b/internal/server/square.go new file mode 100644 index 0000000..caf5267 --- /dev/null +++ b/internal/server/square.go @@ -0,0 +1,35 @@ +package server + +import ( + "fmt" + + "github.com/floppyzedolfin/square/internal/square" + "github.com/floppyzedolfin/square/pkg/logger" + squaredef "github.com/floppyzedolfin/square/pkg/square" + "github.com/gofiber/fiber/v2" +) + +// registerSquare exposes the Square endpoint on the server +func (s *Server) registerSquare() { + s.app.Post("/square", squareWrapper) +} + +// squareWrapper parses the request, calls the intelligent computation function, and returns the result +func squareWrapper(c *fiber.Ctx) error { + req := new(squaredef.Request) + if err := c.BodyParser(req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": fmt.Sprintf("unable to parse body as request: %s", err.Error())}) + } + if err := req.Validate(); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error":"unset request"}) + } + logger.Log(logger.Info, "received request for endpoint square: %s", req) + + // Here lies the endpoint's smartness + res, err := square.Square(*c, *req) + if err != nil { + return c.Status(err.Code).JSON(fiber.Map{"error": err.Message}) + } + + return c.JSON(res) +} diff --git a/internal/square/square.go b/internal/square/square.go new file mode 100644 index 0000000..193902b --- /dev/null +++ b/internal/square/square.go @@ -0,0 +1,24 @@ +package square + +import ( + "github.com/floppyzedolfin/square/pkg/logger" + squaredef "github.com/floppyzedolfin/square/pkg/square" + "github.com/gofiber/fiber/v2" +) + +// Square squares the value contained in the request +func Square(_ fiber.Ctx, req squaredef.Request) (squaredef.Response, *fiber.Error) { + if req.Value == nil { + return squaredef.Response{}, fiber.NewError(fiber.StatusBadRequest, "unset request") + } + v := *req.Value + if v == 0 { + logger.Log(logger.Warning, "You've entered Castle Anthrax!") + return squaredef.Response{}, fiber.NewError(fiber.StatusNotAcceptable, "naught, naught, naught") + } + + // implement logic here + result := v * v + + return squaredef.Response{Value: result}, nil +} diff --git a/internal/square/square_test.go b/internal/square/square_test.go new file mode 100644 index 0000000..286ac65 --- /dev/null +++ b/internal/square/square_test.go @@ -0,0 +1,53 @@ +package square + +import ( + "testing" + + "github.com/floppyzedolfin/square/pkg/square" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSquare(t *testing.T) { + tt := map[string]struct { + inputValue *int + returnCode int + errMsg string + squaredValue int + }{ + "2 squared": { + inputValue: intPtr(2), + squaredValue: 4, + }, + "0 squared - error case": { + inputValue: intPtr(0), + returnCode: fiber.StatusNotAcceptable, + errMsg: "naught, naught, naught", + }, + "invalid request - error case": { + inputValue: nil, + returnCode: fiber.StatusBadRequest, + errMsg: "unset request", + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + req := square.Request{Value: tc.inputValue} + res, err := Square(fiber.Ctx{}, req) + if tc.errMsg != "" { + assert.Error(t, err) + assert.Equal(t, tc.returnCode, err.Code) + assert.Contains(t, err.Error(), tc.errMsg) + } else { + require.Nil(t, err) + assert.Equal(t, tc.squaredValue, res.Value) + } + }) + } +} + +func intPtr(v int) *int { + return &v +} diff --git a/pkg/logger/log.go b/pkg/logger/log.go new file mode 100644 index 0000000..6801a83 --- /dev/null +++ b/pkg/logger/log.go @@ -0,0 +1,75 @@ +package logger + +import ( + "encoding/json" + "fmt" + "io" + "os" + "runtime" + "strings" + "time" +) + +type Level int + +const ( + Debug Level = iota + Info + Warning + Error + noLog // set the defaultLevel to noLog to discard all logs +) + +var ( + // default parameters - they aren't exposed and can't be changed by the rest of the world, but, in order to properly test this, I can't have hardwritten consts + defaultLevel = Debug // change this to change the logging threshold + output io.Writer = os.Stdout // change this to change the default output - check the tests + clock func() time.Time = time.Now // used to get time +) + +// Log prints a message on the standard output. The message has a json format and contains information about the caller +func Log(lvl Level, format string, a ...interface{}) { + if lvl >= defaultLevel { + // Skipping 2 layers in order to get to the caller of this func. + msg := buildLogMessage(lvl, fmt.Sprintf(format, a...), clock, 2) + marshalledLog, _ := json.Marshal(msg) + fmt.Fprintf(output, "%v\n", string(marshalledLog)) + } +} + +var levelNames = map[Level]string{ + Debug: "Debug", + Info: "Info", + Warning: "Warning", + Error: "Error", +} + +type logMessage struct { + Time string `json:"time"` + Level string `json:"level"` + Message string `json:"message"` + Caller string `json:"caller"` +} + +func buildLogMessage(lvl Level, msg string, t func() time.Time, skip int) logMessage { + // get info about the caller + callerFunc := "" + fpcs := make([]uintptr, 1) + // need to go back one step in the stack (to the level of this func's caller + whatever that skips) + n := runtime.Callers(skip+1, fpcs) + if n != 0 { + caller := runtime.FuncForPC(fpcs[0] - 1) + if caller != nil { + callerFunc = caller.Name() + path := strings.Split(callerFunc, string(os.PathSeparator)) + callerFunc = path[len(path)-1] + } + } + + return logMessage{ + Time: t().Format(time.RFC3339Nano), + Level: levelNames[lvl], + Message: msg, + Caller: callerFunc, + } +} diff --git a/pkg/logger/log_test.go b/pkg/logger/log_test.go new file mode 100644 index 0000000..0afc1ce --- /dev/null +++ b/pkg/logger/log_test.go @@ -0,0 +1,60 @@ +package logger + +import ( + "bytes" + "io" + "io/ioutil" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLog(t *testing.T) { + tt := map[string]struct { + defaultLevel Level + output io.ReadWriter + clock func() time.Time + messageLevel Level + messageFormat string + arguments []interface{} + loggedMessage string + }{ + "logging info with debug threshold": { + defaultLevel: Debug, + output: &bytes.Buffer{}, + clock: fakeClock, + messageLevel: Info, + messageFormat: "hello %s", + arguments: []interface{}{"world!"}, + loggedMessage: "{\"time\":\"1984-05-14T01:23:45.000000067Z\",\"level\":\"Info\",\"message\":\"hello world!\",\"caller\":\"logger.TestLog\"}\n", + }, + "logging debug with info threshold": { + defaultLevel: Info, + output: &bytes.Buffer{}, + clock: fakeClock, + messageLevel: Debug, + messageFormat: "hello %s", + arguments: []interface{}{"world!"}, + loggedMessage: "", + }, + } + + for name, tc := range tt { + // set the local parameters for this execution + defaultLevel = tc.defaultLevel + output = tc.output + clock = tc.clock + + Log(tc.messageLevel, tc.messageFormat, tc.arguments...) + res, err := ioutil.ReadAll(tc.output) + require.NoError(t, err, name) + assert.Equal(t, tc.loggedMessage, string(res), name) + } +} + +// fakeClock returns an absolutely random date. +func fakeClock() time.Time { + return time.Date(1984, time.May, 14, 01, 23, 45, 67, time.UTC) +} diff --git a/pkg/square/square_def.go b/pkg/square/square_def.go new file mode 100644 index 0000000..d7e4311 --- /dev/null +++ b/pkg/square/square_def.go @@ -0,0 +1,38 @@ +package square + +import ( + "fmt" + "strings" + + "github.com/jinzhu/gorm" +) + +// Request holds the contents of the request, along with its json fields' names +type Request struct { + gorm.Model // needed to parse this body + Value *int `json:"value` // making it a pointer makes it mandatory, if we implement the checks properly +} + +func (r *Request) Validate() error { + if r.Value == nil { + return fmt.Errorf("invalid request, field Value musn't be empty") + } + return nil +} + +// String implements the stringer interface, and hides the gorm thingy +func (r *Request) String() string { + sb := strings.Builder{} + sb.WriteString("Value: ") + if r.Value == nil { + sb.WriteString("nil") + } else { + sb.WriteString(fmt.Sprintf("%d", *r.Value)) + } + return sb.String() +} + +// Response holds the contents of the response, along with its json fields's names +type Response struct { + Value int `json:"value"` +} diff --git a/pkg/square/square_test.go b/pkg/square/square_test.go new file mode 100644 index 0000000..a92f1e4 --- /dev/null +++ b/pkg/square/square_test.go @@ -0,0 +1,59 @@ +package square + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRequestValidate(t *testing.T) { + anInt := 2 + tt := map[string]struct { + req Request + errMsg string + }{ + "value = 2": { + req: Request{Value: &anInt}, + }, + "unset value": { + req: Request{}, + errMsg: "invalid request, field Value musn't be empty", + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + err := tc.req.Validate() + if tc.errMsg != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.errMsg) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestRequestString(t *testing.T) { + anInt := 3 + tt := map[string]struct { + req Request + resp string + }{ + "value = 2": { + req: Request{Value: &anInt}, + resp: "Value: 3", + }, + "unset value": { + req: Request{}, + resp: "Value: nil", + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + str := tc.req.String() + assert.Equal(t, tc.resp, str) + }) + } +}