From a9cc5a8aadd484bb99e72f525d1c8ccad9d61c94 Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sat, 9 Dec 2023 07:04:17 +0000 Subject: [PATCH] First --- Dockerfile | 11 + README.md | 23 ++ go.mod | 19 + go.sum | 47 +++ main.go | 12 + server/get_guess.go | 50 +++ server/get_guess_test.go | 127 +++++++ server/get_trivia.go | 71 ++++ server/get_trivia_test.go | 145 ++++++++ server/server.go | 44 +++ server/server_main_test.go | 34 ++ trivia.json | 741 +++++++++++++++++++++++++++++++++++++ trivia/questions.go | 109 ++++++ trivia/questions_test.go | 163 ++++++++ trivia/trivia_main_test.go | 44 +++ 15 files changed, 1640 insertions(+) create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 server/get_guess.go create mode 100644 server/get_guess_test.go create mode 100644 server/get_trivia.go create mode 100644 server/get_trivia_test.go create mode 100644 server/server.go create mode 100644 server/server_main_test.go create mode 100644 trivia.json create mode 100644 trivia/questions.go create mode 100644 trivia/questions_test.go create mode 100644 trivia/trivia_main_test.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af268fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +# syntax=docker/dockerfile:1 +FROM golang:1.21 +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY *.go ./ +COPY ./server/*.go ./server/ +COPY ./trivia/*.go ./trivia/ +COPY trivia.json ./ +RUN CGO_ENABLED=0 GOOS=linux go build -o /TriviaAPI +CMD ["/TriviaAPI"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..839c302 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +Stateless, Containerized Trivia API +--- + +This project has a blog post going through the development process. Read the post here: https://mnrva.dev/posts/stateless-containerized-trivia-api-go + +## Running the server +Copy the repo +```bash +git clone git@github.com:gabehf/trivia-api.git +``` +Then, run the server using Go +```bash +cd trivia-api +go run . +``` +Or, build the docker image and run that +```bash +docker build --tag trivia-api . +``` +Then, +```bash +docker run -p 3000:3000 trivia-api +``` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..679ac08 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/gabehf/trivia-api + +go 1.21.4 + +require github.com/labstack/echo/v4 v4.11.3 + +require ( + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/labstack/gommon v0.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/text v0.13.0 // indirect + golang.org/x/time v0.3.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f0a3e81 --- /dev/null +++ b/go.sum @@ -0,0 +1,47 @@ +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/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM= +github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= +github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= +github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/main.go b/main.go new file mode 100644 index 0000000..c04307f --- /dev/null +++ b/main.go @@ -0,0 +1,12 @@ +package main + +import ( + "log" + + "github.com/gabehf/trivia-api/server" +) + +func main() { + log.Println("Trivia API listening on http://127.0.0.1:3000") + log.Fatal(server.Run()) +} diff --git a/server/get_guess.go b/server/get_guess.go new file mode 100644 index 0000000..59666a0 --- /dev/null +++ b/server/get_guess.go @@ -0,0 +1,50 @@ +package server + +import ( + "strings" + + "github.com/labstack/echo/v4" +) + +type GetGuessRequest struct { + QuestionId string `json:"question_id" query:"question_id"` + Guess string `json:"guess" query:"guess"` +} +type GetGuessResponse struct { + QuestionId string `json:"question_id"` + Correct bool `json:"correct"` +} + +func (s *Server) GetGuess(e echo.Context) error { + req := new(GetGuessRequest) + e.Bind(req) + + // ensure required parameters exist + errs := make(map[string]string, 0) + if req.Guess == "" { + errs["guess"] = "required parameter missing" + } + if req.QuestionId == "" { + errs["question_id"] = "required parameter missing" + } + if len(errs) > 0 { + return e.JSON(400, &ErrorResponse{ + Error: true, + Data: errs, + }) + } + + question := s.Q.GetQuestionById(req.QuestionId) + if question == nil { + errs["question_id"] = "invalid or malformed" + return e.JSON(404, &ErrorResponse{ + Error: true, + Data: errs, + }) + } + + // validate answer with case insensitive string compare + correct := strings.EqualFold(question.Answer, req.Guess) + + return e.JSONPretty(200, &GetGuessResponse{req.QuestionId, correct}, " ") +} diff --git a/server/get_guess_test.go b/server/get_guess_test.go new file mode 100644 index 0000000..cc889fe --- /dev/null +++ b/server/get_guess_test.go @@ -0,0 +1,127 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gabehf/trivia-api/server" + "github.com/labstack/echo/v4" +) + +func TestGuessHandler(t *testing.T) { + jsonBody := []byte("{\"question_id\":\"World History|0\",\"guess\":\"seven\"}") + + // OK path: json body + e := echo.New() + req := httptest.NewRequest("GET", "/guess", bytes.NewReader(jsonBody)) + req.Header["Content-Type"] = []string{"application/json"} + res := httptest.NewRecorder() + c := e.NewContext(req, res) + err := S.GetGuess(c) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if res.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", res.Code) + } + + result := new(server.GetGuessResponse) + err = json.Unmarshal(res.Body.Bytes(), result) + if err != nil { + t.Error("malformed json response") + } + if result.QuestionId != "World History|0" { + t.Errorf("expected question_id 'World History|0', got '%s'", result.QuestionId) + } + if result.Correct != true { + t.Errorf("expected correct to be true, got false") + } + + // OK path: urlencoded body + e = echo.New() + req = httptest.NewRequest("GET", "/guess?question_id=World+History%7C0&guess=Seven", nil) + req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"} + res = httptest.NewRecorder() + c = e.NewContext(req, res) + err = S.GetGuess(c) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if res.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", res.Code) + } + + result = new(server.GetGuessResponse) + err = json.Unmarshal(res.Body.Bytes(), result) + if err != nil { + t.Error("malformed json response") + } + if result.QuestionId != "World History|0" { + t.Errorf("expected question_id 'World History|0', got '%s'", result.QuestionId) + } + if result.Correct != true { + t.Errorf("expected correct to be true, got false") + } + + // FAIL path: invalid question id + e = echo.New() + req = httptest.NewRequest("GET", "/guess?question_id=hey&guess=Seven", nil) + req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"} + res = httptest.NewRecorder() + c = e.NewContext(req, res) + err = S.GetGuess(c) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if res.Code != http.StatusNotFound { + t.Errorf("expected status 400 Bad Request, got %d", res.Code) + } + errResult := struct { + Error bool + Data map[string]string + }{} + err = json.Unmarshal(res.Body.Bytes(), &errResult) + if err != nil { + t.Error("malformed json response") + } + if !errResult.Error { + t.Error("expected error to be true, got false") + } + if errResult.Data["question_id"] == "" { + t.Errorf("expected error information in data[question_id], got \"\"") + } + + // FAIL path: missing params + e = echo.New() + req = httptest.NewRequest("GET", "/guess", nil) + req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"} + res = httptest.NewRecorder() + c = e.NewContext(req, res) + err = S.GetGuess(c) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if res.Code != http.StatusBadRequest { + t.Errorf("expected status 400 Bad Request, got %d", res.Code) + } + errResult = struct { + Error bool + Data map[string]string + }{} + err = json.Unmarshal(res.Body.Bytes(), &errResult) + if err != nil { + t.Error("malformed json response") + } + if !errResult.Error { + t.Error("expected error to be true, got false") + } + if errResult.Data["question_id"] == "" { + t.Errorf("expected error information in data[question_id], got \"\"") + } + if errResult.Data["guess"] == "" { + t.Errorf("expected error information in data[guess], got \"\"") + } +} diff --git a/server/get_trivia.go b/server/get_trivia.go new file mode 100644 index 0000000..3ac0a4d --- /dev/null +++ b/server/get_trivia.go @@ -0,0 +1,71 @@ +package server + +import ( + "math/rand" + "strconv" + + "github.com/labstack/echo/v4" +) + +type GetTriviaRequest struct { + Category string `json:"category" query:"category"` +} +type GetTriviaResponse struct { + QuestionId string `json:"question_id"` + Question string `json:"question"` + Category string `json:"category"` + Format string `json:"format"` + Choices map[string]string `json:"choices,omitempty"` +} + +type ErrorResponse struct { + Error bool `json:"error"` + Data map[string]string `json:"data,omitempty"` + Message string `json:"message,omitempty"` +} + +func (s *Server) GetTrivia(e echo.Context) error { + req := new(GetTriviaRequest) + e.Bind(req) + + question, qIndex := s.Q.GetRandomQuestion(req.Category) + if question == nil { + return e.JSON(404, &ErrorResponse{ + Error: true, + Data: map[string]string{ + "category": "category is invalid", + }, + }) + } + // randomly order answer choices if the format is multiple choice + if question.Format == "MultipleChoice" && question.Choices != nil { + rand.Shuffle(len(question.Choices), func(i, j int) { + question.Choices[i], question.Choices[j] = question.Choices[j], question.Choices[i] + }) + // enforce that multiple choice questions must have four choices + // if not, there must be an error in our data somewhere that we need + // to fix + if len(question.Choices) != 4 { + return e.JSON(500, &ErrorResponse{ + Error: true, + Message: "internal server error", + }) + } + } + + // build and return response + tq := new(GetTriviaResponse) + tq.QuestionId = question.Category + "|" + strconv.Itoa(qIndex) + tq.Category = question.Category + tq.Format = question.Format + tq.Question = question.Question + if tq.Format == "MultipleChoice" { + tq.Choices = map[string]string{ + "A": question.Choices[0], + "B": question.Choices[1], + "C": question.Choices[2], + "D": question.Choices[3], + } + } + return e.JSONPretty(200, tq, " ") +} diff --git a/server/get_trivia_test.go b/server/get_trivia_test.go new file mode 100644 index 0000000..a9de211 --- /dev/null +++ b/server/get_trivia_test.go @@ -0,0 +1,145 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gabehf/trivia-api/server" + "github.com/labstack/echo/v4" +) + +func TestTriviaHandler(t *testing.T) { + jsonBody := []byte("{\"category\":\"World History\"}") + + expect := server.GetTriviaResponse{ + Question: "The ancient city of Rome was built on how many hills?", + Format: "MultipleChoice", + Category: "World History", + } + + // OK path: json body + e := echo.New() + req := httptest.NewRequest("GET", "/trivia", bytes.NewReader(jsonBody)) + req.Header["Content-Type"] = []string{"application/json"} + res := httptest.NewRecorder() + c := e.NewContext(req, res) + err := S.GetTrivia(c) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if res.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", res.Code) + } + + result := new(server.GetTriviaResponse) + err = json.Unmarshal(res.Body.Bytes(), result) + if err != nil { + t.Error("malformed json response") + } + if result.Question != expect.Question { + t.Errorf("expected question '%s', got '%s'", expect.Question, result.Question) + } + if result.Format != expect.Format { + t.Errorf("expected format %s, got %s", expect.Format, result.Format) + } + if !strings.EqualFold(expect.Category, result.Category) { + t.Errorf("expected category %s, got %s", expect.Category, result.Category) + } + + // OK path: urlencoded body + e = echo.New() + req = httptest.NewRequest("GET", "/trivia?category=World+History", nil) + req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"} + res = httptest.NewRecorder() + c = e.NewContext(req, res) + err = S.GetTrivia(c) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if res.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", res.Code) + } + expect = server.GetTriviaResponse{ + Question: "The ancient city of Rome was built on how many hills?", + Format: "MultipleChoice", + Category: "World History", + } + result = new(server.GetTriviaResponse) + err = json.Unmarshal(res.Body.Bytes(), result) + if err != nil { + t.Error("malformed json response") + } + if result.Question != expect.Question { + t.Errorf("expected question '%s', got '%s'", expect.Question, result.Question) + } + if result.Format != expect.Format { + t.Errorf("expected format %s, got %s", expect.Format, result.Format) + } + if !strings.EqualFold(expect.Category, result.Category) { + t.Errorf("expected category %s, got %s", expect.Category, result.Category) + } + + // OK path: no body (random category) + e = echo.New() + req = httptest.NewRequest("GET", "/trivia", nil) + res = httptest.NewRecorder() + c = e.NewContext(req, res) + err = S.GetTrivia(c) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if res.Code != http.StatusOK { + t.Errorf("expected status 200 OK, got %d", res.Code) + } + expect = server.GetTriviaResponse{ + Question: "The ancient city of Rome was built on how many hills?", + Format: "MultipleChoice", + Category: "World History", + } + result = new(server.GetTriviaResponse) + err = json.Unmarshal(res.Body.Bytes(), result) + if err != nil { + t.Error("malformed json response") + } + if result.Question != expect.Question { + t.Errorf("expected question '%s', got '%s'", expect.Question, result.Question) + } + if result.Format != expect.Format { + t.Errorf("expected format %s, got %s", expect.Format, result.Format) + } + if !strings.EqualFold(expect.Category, result.Category) { + t.Errorf("expected category %s, got %s", expect.Category, result.Category) + } + + // FAIL path: invalid category + e = echo.New() + req = httptest.NewRequest("GET", "/trivia?category=70s+Music", nil) + req.Header["Content-Type"] = []string{"application/x-www-form-urlencoded"} + res = httptest.NewRecorder() + c = e.NewContext(req, res) + err = S.GetTrivia(c) + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if res.Code != http.StatusNotFound { + t.Errorf("expected status 404 Not Found, got %d", res.Code) + } + errResult := struct { + Error bool + Data map[string]string + }{} + err = json.Unmarshal(res.Body.Bytes(), &errResult) + if err != nil { + t.Error("malformed json response") + } + if !errResult.Error { + t.Error("expected error to be true, got false") + } + if errResult.Data["category"] == "" { + t.Errorf("expected error information in data[category], got \"\"") + } +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..a37e807 --- /dev/null +++ b/server/server.go @@ -0,0 +1,44 @@ +package server + +import ( + "os" + + "github.com/gabehf/trivia-api/trivia" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +type Server struct { + Q *trivia.Questions +} + +func (s *Server) Init() { + s.Q = new(trivia.Questions) + s.Q.Init() +} + +func Run() error { + // init server struct + s := new(Server) + s.Init() + + // load trivia data + file, err := os.Open("trivia.json") + if err != nil { + panic(err) + } + err = s.Q.Load(file) + if err != nil { + panic(err) + } + + // create router and mount handlers + e := echo.New() + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.GET("/trivia", s.GetTrivia) + e.GET("/guess", s.GetGuess) + + // start listening + return e.Start(":3000") +} diff --git a/server/server_main_test.go b/server/server_main_test.go new file mode 100644 index 0000000..2799049 --- /dev/null +++ b/server/server_main_test.go @@ -0,0 +1,34 @@ +package server_test + +import ( + "testing" + + "github.com/gabehf/trivia-api/server" + "github.com/gabehf/trivia-api/trivia" +) + +var S *server.Server + +func TestMain(m *testing.M) { + S = new(server.Server) + S.Init() + S.Q.Init() + S.Q.Categories = []string{"world history"} + S.Q.M = map[string][]trivia.Question{ + "world history": { + { + Question: "The ancient city of Rome was built on how many hills?", + Format: "MultipleChoice", + Category: "World History", + Choices: []string{ + "Eight", + "Four", + "Nine", + "Seven", + }, + Answer: "Seven", + }, + }, + } + m.Run() +} diff --git a/trivia.json b/trivia.json new file mode 100644 index 0000000..382d924 --- /dev/null +++ b/trivia.json @@ -0,0 +1,741 @@ +{ + "world history": [ + { + "category": "World History", + "question": "How many years did the 100 years war last?", + "answer": "116", + "format": "MultipleChoice", + "choices": [ + "116", + "87", + "12", + "205" + ] + }, + { + "category": "World History", + "question": "In which year was John F. Kennedy assassinated?", + "answer": "1963", + "format": "MultipleChoice", + "choices": [ + "1963", + "1967", + "1958", + "1978" + ] + }, + { + "category": "World History", + "question": "Which world leader is famous for his “Little Red Book”?", + "answer": "Mao Zedong", + "format": "MultipleChoice", + "choices": [ + "Mao Zedong", + "Xi Xinping", + "Ho Chi Minh", + "Kim Jong-Un" + ] + }, + { + "category": "World History", + "question": "The ancient city of Rome was built on how many hills?", + "answer": "Seven", + "format": "MultipleChoice", + "choices": [ + "Seven", + "Eight", + "Four", + "Twelve" + ] + }, + { + "category": "World History", + "question": "Before becoming the state of New Jersey, this US state was originally known as New __________.", + "answer": "Netherlands", + "format": "MultipleChoice", + "choices": [ + "Denmark", + "Netherlands", + "Spain", + "England" + ] + }, + { + "category": "World History", + "question": "Which ancient city was famous for its Hanging Gardens?", + "answer": "Babylon", + "format": "MultipleChoice", + "choices": [ + "Mesopotamia", + "Athens", + "Babylon", + "Rome" + ] + }, + { + "category": "World History", + "question": "In which country was Adolf Hitler born?", + "answer": "", + "format": "MultipleChoice", + "choices": [ + "Australia", + "Austria", + "Germany", + "Switzerland" + ] + }, + { + "category": "World History", + "question": "The Hundred Years War was a conflict between the two nations France and who?", + "answer": "England", + "format": "MultipleChoice", + "choices": [ + "England", + "Spain", + "Italy", + "China" + ] + }, + { + "category": "World History", + "question": "Who wrote the document known as the 95 Theses?", + "answer": "Martin Luther", + "format": "MultipleChoice", + "choices": [ + "Martin Luther", + "Pope Francis", + "The Dalai Lama", + "Barack Obama" + ] + }, + { + "category": "World History", + "question": "Victory in Europe Day, or VE Day, celebrates the Allies' victory over Nazi Germany on which date?", + "answer": "May 8th", + "format": "MultipleChoice", + "choices": [ + "May 8th", + "May 9th", + "July 4th", + "January 6th" + ] + }, + { + "category": "World History", + "question": "The Reign of Terror was a period during which major social and political event?", + "answer": "French Revolution", + "format": "MultipleChoice", + "choices": [ + "French Revolution", + "Nazi Occupation of Poland", + "November 11th", + "WWI" + ] + }, + { + "category": "World History", + "question": "Which Chinese destination was the site of protests in 1989?", + "answer": "Tiananmen Square", + "format": "MultipleChoice", + "choices": [ + "Tiananmen Square", + "Taiwan", + "Guangzhou", + "Hangzhou" + ] + }, + { + "category": "World History", + "question": "True or False: Civil rights leader Martin Luther King, Jr won the Nobel Peace prize in 1964.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "World History", + "question": "True or False: John Wilkes Booth assassinated Abraham Lincoln.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "World History", + "question": "True or False: Cleopatra, the last Queen of Egypt, was born in Rome", + "answer": "False", + "format": "TrueFalse" + } + ], + "art": [ + { + "category": "Art", + "question": "Banksy's artwork started appearing on walls in which decade?", + "answer": "1990s", + "format": "MultipleChoice", + "choices": [ + "1990s", + "2000s", + "2010s", + "1970s" + ] + }, + { + "category": "Art", + "question": "Which female Mexican artist is famous for self-portraits?", + "answer": "Frida Kahlo", + "format": "MultipleChoice", + "choices": [ + "Leonora Carrington", + "Sofía Táboas", + "Frida Kahlo", + "Elizabeth Catlett" + ] + }, + { + "category": "Art", + "question": "Renoir was one of the leading artists in which artistic movement?", + "answer": "Impressionism", + "format": "MultipleChoice", + "choices": [ + "Impressionism", + "Realism", + "Surrealism", + "New Wave" + ] + }, + { + "category": "Art", + "question": "Which rebel leader was featured in the Pop Art of Andy Warhol?", + "answer": "Che Guevara", + "format": "MultipleChoice", + "choices": [ + "Luke Skywalker", + "Che Guevara", + "Marilyn Monroe", + "Fidel Castro" + ] + }, + { + "category": "Art", + "question": "Katsushika Hokusai painted The Great _______ off Kanagawa circa 1830.", + "answer": "Wave", + "format": "MultipleChoice", + "choices": [ + "Wave", + "Fleet", + "Island", + "Skies" + ] + }, + { + "category": "Art", + "question": "The Starry Night Over The Rhone was painted in 1888 by which artist?", + "answer": "Van Gogh", + "format": "MultipleChoice", + "choices": [ + "Monet", + "Da Vinci", + "Van Gogh", + "Raphael" + ] + }, + { + "category": "Art", + "question": "Vincent Van Gogh is considered by many to be Post-Impressionist, or \"The Father of ________\".", + "answer": "Expressionism", + "format": "MultipleChoice", + "choices": [ + "Expressionism", + "Impressionism", + "Modernism", + "Post-Modernism" + ] + }, + { + "category": "Art", + "question": "In Botticelli's \"Birth of Venus\" (c. 1485), Venus is standing on a(n) what?", + "answer": "Shell", + "format": "MultipleChoice", + "choices": [ + "Shell", + "Wave", + "Rock", + "Whale" + ] + }, + { + "category": "Art", + "question": "Johannes Vermeer's \"Girl with a Pearl ________\" (c. 1665) shares its name with a 2003 Romance film starring Scarlett Johansson.", + "answer": "Earring", + "format": "MultipleChoice", + "choices": [ + "Necklace", + "Earring", + "Hairclip", + "Bracelet" + ] + }, + { + "category": "Art", + "question": "Alexandros of Antioch's \"Venus de Milo\" (c. 150 BC) is a sculpture depicting which greek goddess?", + "answer": "Aphrodite", + "format": "MultipleChoice", + "choices": [ + "Aphrodite", + "Artemis", + "Persephone", + "Hestia" + ] + }, + { + "category": "Art", + "question": "\"The Night Watch\" is one of the most famous works by which artist?", + "answer": "Rembrandt", + "format": "MultipleChoice", + "choices": [ + "Monet", + "Van Gogh", + "Rembrandt", + "Renoir" + ] + }, + { + "category": "Art", + "question": "True or False: The Smithsonian American Art Museum is in Washington, D.C.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "Art", + "question": "Which artistic movement includes works like 'Starry Night' by Vincent van Gogh?", + "answer": "Post-Impressionism", + "format": "MultipleChoice", + "choices": ["Cubism", "Surrealism", "Impressionism", "Post-Impressionism"] + }, + { + "category": "Art", + "question": "True or False: The painting 'The Persistence of Memory' features melting clocks.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "Art", + "question": "True or False: Claude Monet and Edgar Degas are both examples of Renaissance artists.", + "answer": "False", + "format": "TrueFalse" + } + ], + "movies": [ + { + "category": "Movies", + "question": "In which film did Humphrey Bogart say the famous line, 'Here's looking at you, kid'?", + "answer": "Casablanca", + "format": "MultipleChoice", + "choices": ["The Maltese Falcon", "Casablanca", "Key Largo", "To Have and Have Not"] + }, + { + "category": "Movies", + "question": "Who directed the 1994 film 'Pulp Fiction'?", + "answer": "Quentin Tarantino", + "format": "MultipleChoice", + "choices": ["Martin Scorsese", "Quentin Tarantino", "Coen Brothers", "Steven Spielberg"] + }, + { + "category": "Movies", + "question": "Which animated film features a character named Simba?", + "answer": "The Lion King", + "format": "MultipleChoice", + "choices": ["Aladdin", "Beauty and the Beast", "The Lion King", "Toy Story"] + }, + { + "category": "Movies", + "question": "In 'The Shawshank Redemption,' what does Andy Dufresne carve his way through to escape from prison?", + "answer": "Wall", + "format": "MultipleChoice", + "choices": ["Floor", "Wall", "Ceiling", "Cell Bars"] + }, + { + "category": "Movies", + "question": "Which actor played the role of Tony Stark in the Marvel Cinematic Universe?", + "answer": "Robert Downey Jr.", + "format": "MultipleChoice", + "choices": ["Chris Evans", "Mark Ruffalo", "Chris Hemsworth", "Robert Downey Jr."] + }, + { + "category": "Movies", + "question": "True or False: 'The Godfather' is based on a novel of the same name by Mario Puzo.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "Movies", + "question": "Who won the Academy Award for Best Actress for her role in 'La La Land'?", + "answer": "Emma Stone", + "format": "MultipleChoice", + "choices": ["Emma Stone", "Meryl Streep", "Natalie Portman", "Jennifer Lawrence"] + }, + { + "category": "Movies", + "question": "Which film features a character named Travis Bickle who drives a taxi in New York City?", + "answer": "Taxi Driver", + "format": "MultipleChoice", + "choices": ["Collateral", "Nightcrawler", "Drive", "Taxi Driver"] + }, + { + "category": "Movies", + "question": "What is the name of the fictional African country in 'Black Panther'?", + "answer": "Wakanda", + "format": "MultipleChoice", + "choices": ["Zamunda", "Wakanda", "Genovia", "Elbonia"] + }, + { + "category": "Movies", + "question": "True or False: 'Forrest Gump' is based on a novel by Winston Groom.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "Movies", + "question": "Who directed the 2017 horror film 'Get Out'?", + "answer": "Jordan Peele", + "format": "MultipleChoice", + "choices": ["Jordan Peele", "Ari Aster", "John Krasinski", "James Wan"] + }, + { + "category": "Movies", + "question": "Which James Cameron film features the line, 'I'll be back'?", + "answer": "The Terminator", + "format": "MultipleChoice", + "choices": ["Aliens", "The Terminator", "Avatar", "Titanic"] + }, + { + "category": "Movies", + "question": "Who played the lead role in the film 'The Matrix'?", + "answer": "Keanu Reeves", + "format": "MultipleChoice", + "choices": ["Keanu Reeves", "Tom Cruise", "Will Smith", "Brad Pitt"] + }, + { + "category": "Movies", + "question": "True or False: 'The Dark Knight' was directed by Christopher Nolan.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "Movies", + "question": "In the film 'Inception,' what is used to enter the dream world?", + "answer": "Dream Machine", + "format": "MultipleChoice", + "choices": ["Dreamcatcher", "Dream Weaver", "Dream Machine", "Dream Portal"] + } + ], + "music": [ + { + "category": "Music", + "question": "Which band released the album 'The Dark Side of the Moon' in 1973?", + "answer": "Pink Floyd", + "format": "MultipleChoice", + "choices": ["Led Zeppelin", "The Beatles", "Pink Floyd", "The Rolling Stones"] + }, + { + "category": "Music", + "question": "Who is known as the 'King of Pop'?", + "answer": "Michael Jackson", + "format": "MultipleChoice", + "choices": ["Elvis Presley", "Michael Jackson", "Prince", "David Bowie"] + }, + { + "category": "Music", + "question": "Which female artist released the album '21' in 2011?", + "answer": "Adele", + "format": "MultipleChoice", + "choices": ["Taylor Swift", "Adele", "Beyoncé", "Rihanna"] + }, + { + "category": "Music", + "question": "In which year did The Beatles release their debut album 'Please Please Me'?", + "answer": "1963", + "format": "MultipleChoice", + "choices": ["1962", "1963", "1964", "1965"] + }, + { + "category": "Music", + "question": "Who is the lead singer of the rock band U2?", + "answer": "Bono", + "format": "MultipleChoice", + "choices": ["Chris Martin", "Bono", "Mick Jagger", "Bruce Springsteen"] + }, + { + "category": "Music", + "question": "True or False: Elvis Presley is also known as 'The Boss.'", + "answer": "False", + "format": "TrueFalse" + }, + { + "category": "Music", + "question": "Which artist won the Nobel Prize in Literature in 2016?", + "answer": "Bob Dylan", + "format": "MultipleChoice", + "choices": ["Bob Dylan", "Joni Mitchell", "Leonard Cohen", "Paul Simon"] + }, + { + "category": "Music", + "question": "In which year did Nirvana release their iconic album 'Nevermind'?", + "answer": "1991", + "format": "MultipleChoice", + "choices": ["1990", "1991", "1992", "1993"] + }, + { + "category": "Music", + "question": "Which headline performers took the stage at the halftime show of Super Bowl LIV in 2020?", + "answer": "Shakira and Jennifer Lopez", + "format": "MultipleChoice", + "choices": ["Coldplay", "Shakira and Jennifer Lopez", "Maroon 5", "U2"] + }, + { + "category": "Music", + "question": "True or False: Madonna's real name is Madonna.", + "answer": "False", + "format": "TrueFalse" + }, + { + "category": "Music", + "question": "Which 1970's pop wrote the 1974 ballad 'Don't Let the Sun Go Down on Me'?", + "answer": "Elton John", + "format": "MultipleChoice", + "choices": ["David Bowie", "Elton John", "Freddie Mercury", "Billy Joel"] + }, + { + "category": "Music", + "question": "Which pop star is known as the 'Material Girl'?", + "answer": "Madonna", + "format": "MultipleChoice", + "choices": ["Britney Spears", "Lady Gaga", "Madonna", "Katy Perry"] + }, + { + "category": "Music", + "question": "Who wrote the song 'Bohemian Rhapsody'?", + "answer": "Freddie Mercury", + "format": "MultipleChoice", + "choices": ["Freddie Mercury", "John Lennon", "David Bowie", "Elton John"] + }, + { + "category": "Music", + "question": "True or False: Adele's full name is Adele Laurie Blue Adkins.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "Music", + "question": "Which country is the birthplace of the band ABBA?", + "answer": "Sweden", + "format": "MultipleChoice", + "choices": ["Norway", "Denmark", "Sweden", "Finland"] + } + ], + "geography": [ + { + "category": "Geography", + "question": "What is the capital city of Japan?", + "answer": "Tokyo", + "format": "MultipleChoice", + "choices": ["Beijing", "Seoul", "Bangkok", "Tokyo"] + }, + { + "category": "Geography", + "question": "Which river is the longest in the world?", + "answer": "Nile", + "format": "MultipleChoice", + "choices": ["Amazon", "Nile", "Yangtze", "Mississippi"] + }, + { + "category": "Geography", + "question": "The __________ Desert is the largest desert in the world?", + "answer": "Antarctic", + "format": "MultipleChoice", + "choices": ["Sahara", "Gobi", "Arabian", "Antarctic"] + }, + { + "category": "Geography", + "question": "Which country is known as the 'Land of the Rising Sun'?", + "answer": "Japan", + "format": "MultipleChoice", + "choices": ["China", "South Korea", "Japan", "Vietnam"] + }, + { + "category": "Geography", + "question": "What is the capital city of Australia?", + "answer": "Canberra", + "format": "MultipleChoice", + "choices": ["Sydney", "Melbourne", "Canberra", "Brisbane"] + }, + { + "category": "Geography", + "question": "True or False: The Amazon Rainforest is located in Africa.", + "answer": "False", + "format": "TrueFalse" + }, + { + "category": "Geography", + "question": "Which mountain is the tallest in North America?", + "answer": "Denali", + "format": "MultipleChoice", + "choices": ["Mount McKinley", "Mount Logan", "Denali", "Mount St. Elias"] + }, + { + "category": "Geography", + "question": "In which continent is the Sahara Desert located?", + "answer": "Africa", + "format": "MultipleChoice", + "choices": ["Asia", "Africa", "South America", "Australia"] + }, + { + "category": "Geography", + "question": "Which ocean is the largest on Earth?", + "answer": "Pacific Ocean", + "format": "MultipleChoice", + "choices": ["Atlantic Ocean", "Indian Ocean", "Southern Ocean", "Pacific Ocean"] + }, + { + "category": "Geography", + "question": "True or False: The Great Wall of China is visible from space without aid.", + "answer": "False", + "format": "TrueFalse" + }, + { + "category": "Geography", + "question": "Which river flows through Cairo?", + "answer": "Nile", + "format": "MultipleChoice", + "choices": ["Tigris", "Euphrates", "Nile", "Jordan"] + }, + { + "category": "Geography", + "question": "What is the smallest country in the world?", + "answer": "Vatican City", + "format": "MultipleChoice", + "choices": ["Monaco", "San Marino", "Liechtenstein", "Vatican City"] + }, + { + "category": "Geography", + "question": "Which African country is known as the 'Pearl of Africa'?", + "answer": "Uganda", + "format": "MultipleChoice", + "choices": ["Kenya", "Tanzania", "Uganda", "Rwanda"] + }, + { + "category": "Geography", + "question": "True or False: Mount Everest is located in the Himalayas.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "Geography", + "question": "Which strait separates Europe and Asia?", + "answer": "Bosporus", + "format": "MultipleChoice", + "choices": ["Strait of Gibraltar", "Bering Strait", "Bosporus", "Strait of Hormuz"] + } + ], + "science": [ + { + "category": "Science", + "question": "What is the chemical symbol for the element gold?", + "answer": "Au", + "format": "MultipleChoice", + "choices": ["Ag", "Fe", "Au", "Cu"] + }, + { + "category": "Science", + "question": "Which planet is known as the 'Red Planet'?", + "answer": "Mars", + "format": "MultipleChoice", + "choices": ["Venus", "Mars", "Jupiter", "Saturn"] + }, + { + "category": "Science", + "question": "What is the powerhouse of the cell?", + "answer": "Mitochondria", + "format": "MultipleChoice", + "choices": ["Nucleus", "Mitochondria", "Endoplasmic Reticulum", "Golgi Apparatus"] + }, + { + "category": "Science", + "question": "What is the largest mammal in the world?", + "answer": "Blue Whale", + "format": "MultipleChoice", + "choices": ["Elephant", "Giraffe", "Blue Whale", "Hippopotamus"] + }, + { + "category": "Science", + "question": "Which gas is primarily responsible for the Earth's greenhouse effect?", + "answer": "Carbon Dioxide", + "format": "MultipleChoice", + "choices": ["Oxygen", "Nitrogen", "Carbon Dioxide", "Methane"] + }, + { + "category": "Science", + "question": "True or False: Water boils at 100 degrees Celsius at sea level.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "Science", + "question": "What is the chemical symbol for the element with the atomic number 1?", + "answer": "H", + "format": "MultipleChoice", + "choices": ["He", "H", "Li", "Be"] + }, + { + "category": "Science", + "question": "Which scientist developed the theory of general relativity?", + "answer": "Albert Einstein", + "format": "MultipleChoice", + "choices": ["Isaac Newton", "Galileo Galilei", "Marie Curie", "Albert Einstein"] + }, + { + "category": "Science", + "question": "What is the chemical symbol for water?", + "answer": "H2O", + "format": "MultipleChoice", + "choices": ["CO2", "O2", "H2O", "N2"] + }, + { + "category": "Science", + "question": "True or False: An adult human skeleton has 206 bones.", + "answer": "True", + "format": "TrueFalse" + }, + { + "category": "Science", + "question": "Which planet was referred to as the 'Pale Blue Dot'?", + "answer": "Earth", + "format": "MultipleChoice", + "choices": ["Mars", "Venus", "Earth", "Neptune"] + }, + { + "category": "Science", + "question": "What is the smallest unit of matter?", + "answer": "Atom", + "format": "MultipleChoice", + "choices": ["Molecule", "Cell", "Atom", "Proton"] + }, + { + "category": "Science", + "question": "Which gas do plants absorb from the atmosphere during photosynthesis?", + "answer": "Carbon Dioxide", + "format": "MultipleChoice", + "choices": ["Oxygen", "Methane", "Carbon Monoxide", "Carbon Dioxide"] + }, + { + "category": "Science", + "question": "True or False: Antibiotics are effective against viruses.", + "answer": "False", + "format": "TrueFalse" + }, + { + "category": "Science", + "question": "What is the largest planet in our solar system?", + "answer": "Jupiter", + "format": "MultipleChoice", + "choices": ["Mars", "Saturn", "Jupiter", "Neptune"] + } + ] + } \ No newline at end of file diff --git a/trivia/questions.go b/trivia/questions.go new file mode 100644 index 0000000..80c6ef0 --- /dev/null +++ b/trivia/questions.go @@ -0,0 +1,109 @@ +package trivia + +import ( + "encoding/json" + "io" + "math/rand" + "strconv" + "strings" + "sync" +) + +// represents the structure of trivia questions stored +// in the trivia.json file +type Question struct { + Question string `json:"question"` + Category string `json:"category"` + Format string `json:"format"` + Choices []string `json:"choices"` + Answer string `json:"answer"` +} + +type Questions struct { + Categories []string + M map[string][]Question + lock *sync.RWMutex +} + +func (q *Questions) Init() { + q.lock = &sync.RWMutex{} +} + +func (q *Questions) Load(r io.Reader) error { + q.lock.Lock() + defer q.lock.Unlock() + if q.M == nil { + q.M = make(map[string][]Question, 0) + } + err := json.NewDecoder(r).Decode(&q.M) + if err != nil { + return err + } + if q.Categories == nil { + q.Categories = make([]string, 0) + } + for key := range q.M { + q.Categories = append(q.Categories, key) + } + return nil +} + +func (q *Questions) categoryExists(cat string) bool { + q.lock.RLock() + defer q.lock.RUnlock() + cat = strings.ToLower(cat) + if q.M[cat] == nil || len(q.M[cat]) < 1 { + return false + } + return true +} + +func (q *Questions) getRandomCategory() string { + q.lock.RLock() + defer q.lock.RUnlock() + return q.Categories[rand.Int()%len(q.Categories)] +} + +// Gets a random question from the category, if specified. +func (q *Questions) GetRandomQuestion(category string) (*Question, int) { + q.lock.RLock() + defer q.lock.RUnlock() + // NOTE: it is okay to call another function that locks the RWMutex here, + // as it will not cause a deadlock since CategoryExists only locks the Read + if category == "" { + category = q.getRandomCategory() + } else if !q.categoryExists(category) { + return nil, 0 + } + category = strings.ToLower(category) + qIndex := rand.Int() % len(q.M[category]) + return &q.M[category][qIndex], qIndex +} + +func (q *Questions) GetQuestionById(id string) *Question { + // get values from question_id + questionSlice := strings.Split(id, "|") + if len(questionSlice) != 2 { + return nil + } + category, indexS := questionSlice[0], questionSlice[1] + category = strings.ToLower(category) + index, err := strconv.Atoi(indexS) + if err != nil { + return nil + } + + q.lock.RLock() + defer q.lock.RUnlock() + // ensure category exists + if !q.categoryExists(category) { + return nil + } + // ensure question index is valid + if len(q.M[category]) <= index { + return nil + } + + // retrieve question + return &q.M[category][index] +} diff --git a/trivia/questions_test.go b/trivia/questions_test.go new file mode 100644 index 0000000..256c50d --- /dev/null +++ b/trivia/questions_test.go @@ -0,0 +1,163 @@ +package trivia_test + +import ( + "bytes" + "reflect" + "slices" + "testing" + + "github.com/gabehf/trivia-api/trivia" +) + +func TestGetRandomQuestion(t *testing.T) { + // on OK path, GetTrivia must return the question in our test data + tq, _ := Q.GetRandomQuestion("world history") + if tq == nil { + t.Fatal("trivia question must not be nil") + } + if !reflect.DeepEqual(tq, expect) { + t.Errorf("returned question does not match expectation, got %v", tq) + } + + // with no category specified, GetTrivia must pick a random category and fetch a question + // with only one question in our test data, it is the same question from before + tq, _ = Q.GetRandomQuestion("") + if tq == nil { + t.Fatal("trivia question must not be nil") + } + if !reflect.DeepEqual(tq, expect) { + t.Errorf("returned question does not match expectation, got %v", tq) + } + + // on FAIL path, GetTrivia must return nil to indicate no questions are found + tq, _ = Q.GetRandomQuestion("Geography") + if tq != nil { + t.Errorf("expected nil, got %v", tq) + } +} + +func TestGetQuestionById(t *testing.T) { + // on OK path, GetTrivia must return the question in our test data + tq := Q.GetQuestionById("world History|0") + if tq == nil { + t.Fatal("trivia question must not be nil") + } + if !reflect.DeepEqual(tq, expect) { + t.Errorf("returned question does not match expectation, got %v", tq) + } + + // FAIL path: malformed id + tq = Q.GetQuestionById("hey") + if tq != nil { + t.Errorf("expected nil, got %v", tq) + } + // FAIL path: invalid category + tq = Q.GetQuestionById("hey|0") + if tq != nil { + t.Errorf("expected nil, got %v", tq) + } + // FAIL path: invalid index + tq = Q.GetQuestionById("world history|9") + if tq != nil { + t.Errorf("expected nil, got %v", tq) + } +} + +func TestLoad(t *testing.T) { + json := []byte(` + { + "world history": [ + { + "category": "World History", + "question": "How many years did the 100 years war last?", + "answer": "116", + "format": "MultipleChoice", + "choices": [ + "116", + "87", + "12", + "205" + ] + }, + { + "category": "World History", + "question": "True or False: John Wilkes Booth assassinated Abraham Lincoln.", + "answer": "True", + "format": "TrueFalse" + } + ], + "geography": [ + { + "category": "Geography", + "question": "What is the capital city of Japan?", + "answer": "Tokyo", + "format": "MultipleChoice", + "choices": [ + "Beijing", + "Seoul", + "Bangkok", + "Tokyo" + ] + }, + { + "category": "Geography", + "question": "True or False: The Amazon Rainforest is located in Africa.", + "answer": "False", + "format": "TrueFalse" + } + ] + } + `) + expectCategories := []string{"world history", "geography"} + expectQuestions := map[string][]trivia.Question{ + "world history": { + { + Question: "How many years did the 100 years war last?", + Format: "MultipleChoice", + Answer: "116", + Category: "World History", + Choices: []string{ + "116", + "87", + "12", + "205", + }, + }, + { + Question: "True or False: John Wilkes Booth assassinated Abraham Lincoln.", + Format: "TrueFalse", + Answer: "True", + Category: "World History", + }, + }, + "geography": { + { + Question: "What is the capital city of Japan?", + Format: "MultipleChoice", + Answer: "Tokyo", + Category: "Geography", + Choices: []string{"Beijing", "Seoul", "Bangkok", "Tokyo"}, + }, + { + Question: "True or False: The Amazon Rainforest is located in Africa.", + Format: "TrueFalse", + Answer: "False", + Category: "Geography", + }, + }, + } + qq := new(trivia.Questions) + qq.Init() + err := qq.Load(bytes.NewReader(json)) + if err != nil { + t.Errorf("expected error to be nil, got %v", err) + } + for _, cat := range expectCategories { + if !slices.Contains(qq.Categories, cat) { + t.Errorf("expected category %s not present", cat) + } + } + if !reflect.DeepEqual(qq.M, expectQuestions) { + t.Errorf("unexpected question map, got %v", qq.M) + } +} diff --git a/trivia/trivia_main_test.go b/trivia/trivia_main_test.go new file mode 100644 index 0000000..77ea8db --- /dev/null +++ b/trivia/trivia_main_test.go @@ -0,0 +1,44 @@ +package trivia_test + +import ( + "testing" + + "github.com/gabehf/trivia-api/trivia" +) + +var Q trivia.Questions +var expect *trivia.Question + +func TestMain(m *testing.M) { + Q.Init() + Q.Categories = []string{"world history"} + Q.M = map[string][]trivia.Question{ + "world history": { + { + Question: "The ancient city of Rome was built on how many hills?", + Format: "MultipleChoice", + Category: "World History", + Choices: []string{ + "Eight", + "Four", + "Nine", + "Seven", + }, + Answer: "Seven", + }, + }, + } + expect = &trivia.Question{ + Question: "The ancient city of Rome was built on how many hills?", + Category: "World History", + Format: "MultipleChoice", + Choices: []string{ + "Eight", + "Four", + "Nine", + "Seven", + }, + Answer: "Seven", + } + m.Run() +}