From eb2ff2f5690f2935594e4ceb33f1ced141f4f504 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pawe=C5=82=20J=2E=20Wal?= <p@steamshard.net>
Date: Sun, 16 Jun 2024 16:28:18 +0200
Subject: [PATCH] #1 Add naive search

---
 api/application.go            |   6 ++
 api/search_controller.go      |  41 +++++++++++++
 api/search_controller_test.go | 106 ++++++++++++++++++++++++++++++++++
 container.go                  |  11 ++++
 docs/docs.go                  |  30 ++++++++++
 docs/swagger.json             |  30 ++++++++++
 docs/swagger.yaml             |  20 +++++++
 go.mod                        |   1 +
 go.sum                        |   2 +
 mal/anime.go                  |  11 ++++
 mal/client.go                 |  49 ++++++++++++++++
 mal/client_test.go            |  83 ++++++++++++++++++++++++++
 mocks/mal_client.go           |  16 +++++
 13 files changed, 406 insertions(+)
 create mode 100644 api/search_controller.go
 create mode 100644 api/search_controller_test.go
 create mode 100644 mal/anime.go
 create mode 100644 mal/client.go
 create mode 100644 mal/client_test.go
 create mode 100644 mocks/mal_client.go

diff --git a/api/application.go b/api/application.go
index 46509d3..ee5baf3 100644
--- a/api/application.go
+++ b/api/application.go
@@ -40,6 +40,7 @@ type Application struct {
 	SessionsController *SessionsController
 	MeController       *MeController
 	HealthController   *HealthController
+	SearchController   *SearchController
 
 	// Middleware
 	JwtMiddleware *JwtMiddleware
@@ -53,6 +54,7 @@ type ProvideApplicationParams struct {
 	SessionsController *SessionsController
 	MeController       *MeController
 	HealthController   *HealthController
+	SearchController   *SearchController
 
 	JwtMiddleware *JwtMiddleware
 	Logger        *zap.Logger
@@ -66,6 +68,7 @@ func NewApplication(p ProvideApplicationParams) *Application {
 		SessionsController: p.SessionsController,
 		MeController:       p.MeController,
 		HealthController:   p.HealthController,
+		SearchController:   p.SearchController,
 
 		JwtMiddleware: p.JwtMiddleware,
 	}
@@ -130,6 +133,9 @@ func (s *Application) route() {
 	// Me controller
 	v1.GET("/me", s.MeController.Show)
 
+	// Search controller
+	v1.GET("/search", s.SearchController.Show)
+
 	// Development static
 	if s.config.EnableStatic {
 		s.httpHandler.Static("/devstc", "development-static")
diff --git a/api/search_controller.go b/api/search_controller.go
new file mode 100644
index 0000000..5719e9d
--- /dev/null
+++ b/api/search_controller.go
@@ -0,0 +1,41 @@
+package api
+
+import (
+	"github.com/labstack/echo/v4"
+	"net/http"
+
+	"steamshard.net/oppai-api/mal"
+)
+
+type SearchController struct {
+	malClient mal.IClient
+}
+
+func NewSearchController(malClient mal.IClient) *SearchController {
+	return &SearchController{
+		malClient: malClient,
+	}
+}
+
+// Show godoc
+//
+//	@Summary	Search for anime
+//	@Tags		anime
+//	@Produce	json
+//	@Param		q	query		string	true	"Query"
+//	@Success	200	{array}		mal.Anime
+//	@Failure	500	{object}	Error
+func (r SearchController) Show(c echo.Context) error {
+	query := c.QueryParam("q")
+	if query == "" {
+		return RespondError(c, http.StatusBadRequest, "Query parameter is required")
+	}
+
+	animes, err := r.malClient.SearchAnime(query)
+
+	if err != nil {
+		return RespondError(c, http.StatusInternalServerError, "Failed to search anime")
+	}
+
+	return c.JSON(http.StatusOK, animes)
+}
diff --git a/api/search_controller_test.go b/api/search_controller_test.go
new file mode 100644
index 0000000..92b7a56
--- /dev/null
+++ b/api/search_controller_test.go
@@ -0,0 +1,106 @@
+package api_test
+
+import (
+	"github.com/labstack/echo/v4"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/suite"
+	"net/http/httptest"
+	"testing"
+
+	"steamshard.net/oppai-api/api"
+	"steamshard.net/oppai-api/mal"
+	"steamshard.net/oppai-api/mocks"
+)
+
+type SearchControllerTestSuite struct {
+	suite.Suite
+
+	mockMalClient *mocks.MockMalClient
+
+	subject *api.SearchController
+}
+
+func (suite *SearchControllerTestSuite) SetupTest() {
+	suite.mockMalClient = &mocks.MockMalClient{}
+	suite.subject = api.NewSearchController(suite.mockMalClient)
+}
+
+func (suite *SearchControllerTestSuite) TestSearch() {
+	anime := mal.Anime{
+		ID:            0,
+		MalID:         48736,
+		Title:         "Sono Bisque Doll wa Koi wo Suru",
+		TitleEnglish:  "My Dress-Up Darling",
+		TitleJapanese: "その着せ替え人形は恋をする",
+		TitleSynonyms: []string{"Sono Kisekae Ningyou wa Koi wo Suru", "KiseKoi"},
+		ImageUrl:      "https://example.local/url.jpeg",
+	}
+
+	suite.mockMalClient.On("SearchAnime", "Sono Bisque Doll wa Koi wo Suru").Return([]mal.Anime{anime}, nil)
+
+	request := httptest.NewRequest("GET", "/search?q=Sono%20Bisque%20Doll%20wa%20Koi%20wo%20Suru", nil)
+	recorder := httptest.NewRecorder()
+
+	e := echo.New()
+	context := e.NewContext(request, recorder)
+	err := suite.subject.Show(context)
+	suite.NoError(err)
+	suite.Equal(200, recorder.Code)
+	suite.JSONEq(`[{
+		"id": 0,
+		"mal_id": 48736,
+		"title": "Sono Bisque Doll wa Koi wo Suru",
+		"title_english": "My Dress-Up Darling",
+		"title_japanese": "その着せ替え人形は恋をする",
+		"title_synonyms": ["Sono Kisekae Ningyou wa Koi wo Suru", "KiseKoi"],
+		"image_url": "https://example.local/url.jpeg"
+	}]`, recorder.Body.String())
+}
+
+func (suite *SearchControllerTestSuite) TestEmptyQuery() {
+	request := httptest.NewRequest("GET", "/search", nil)
+	recorder := httptest.NewRecorder()
+
+	e := echo.New()
+	context := e.NewContext(request, recorder)
+	err := suite.subject.Show(context)
+	suite.NoError(err)
+	suite.Equal(400, recorder.Code)
+	suite.JSONEq(`{
+		"message": "Query parameter is required"
+	}`, recorder.Body.String())
+}
+
+func (suite *SearchControllerTestSuite) TestClientError() {
+	suite.mockMalClient.On("SearchAnime", "Sono Bisque Doll wa Koi wo Suru").Return([]mal.Anime{}, assert.AnError)
+
+	request := httptest.NewRequest("GET", "/search?q=Sono%20Bisque%20Doll%20wa%20Koi%20wo%20Suru", nil)
+	recorder := httptest.NewRecorder()
+
+	e := echo.New()
+	context := e.NewContext(request, recorder)
+	err := suite.subject.Show(context)
+	suite.NoError(err)
+	suite.Equal(500, recorder.Code)
+	suite.JSONEq(`{
+		"message": "Failed to search anime"
+	}`, recorder.Body.String())
+}
+
+func (suite *SearchControllerTestSuite) TestEmptyResult() {
+	suite.mockMalClient.On("SearchAnime", "Sono Bisque Doll wa Koi wo Suru").Return([]mal.Anime{}, nil)
+
+	request := httptest.NewRequest("GET", "/search?q=Sono%20Bisque%20Doll%20wa%20Koi%20wo%20Suru", nil)
+	recorder := httptest.NewRecorder()
+
+	e := echo.New()
+	context := e.NewContext(request, recorder)
+	err := suite.subject.Show(context)
+	suite.NoError(err)
+	suite.Equal(200, recorder.Code)
+	suite.JSONEq(`[]`, recorder.Body.String())
+}
+
+func TestSearchControllerTestSuite(t *testing.T) {
+	suite.Run(t, new(SearchControllerTestSuite))
+}
diff --git a/container.go b/container.go
index 1259471..0e71191 100644
--- a/container.go
+++ b/container.go
@@ -5,6 +5,7 @@ import (
 
 	"steamshard.net/oppai-api/api"
 	"steamshard.net/oppai-api/config"
+	"steamshard.net/oppai-api/mal"
 	"steamshard.net/oppai-api/models"
 	"steamshard.net/oppai-api/tasks"
 )
@@ -52,6 +53,11 @@ func NewContainer() (*dig.Container, error) {
 		return nil, err
 	}
 
+	err = container.Provide(api.NewSearchController)
+	if err != nil {
+		return nil, err
+	}
+
 	err = container.Provide(tasks.NewQueueManager)
 	if err != nil {
 		return nil, err
@@ -72,5 +78,10 @@ func NewContainer() (*dig.Container, error) {
 		return nil, err
 	}
 
+	err = container.Provide(mal.NewClient)
+	if err != nil {
+		return nil, err
+	}
+
 	return container, nil
 }
diff --git a/docs/docs.go b/docs/docs.go
index 5499a89..bd0fb76 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -305,6 +305,36 @@ const docTemplate = `{
                     "type": "string"
                 }
             }
+        },
+        "mal.Anime": {
+            "type": "object",
+            "properties": {
+                "id": {
+                    "description": "This will be populated from the DB later",
+                    "type": "integer"
+                },
+                "image_url": {
+                    "type": "string"
+                },
+                "mal_id": {
+                    "type": "integer"
+                },
+                "title": {
+                    "type": "string"
+                },
+                "title_english": {
+                    "type": "string"
+                },
+                "title_japanese": {
+                    "type": "string"
+                },
+                "title_synonyms": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                }
+            }
         }
     },
     "securityDefinitions": {
diff --git a/docs/swagger.json b/docs/swagger.json
index 4d170dd..c16389d 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -299,6 +299,36 @@
                     "type": "string"
                 }
             }
+        },
+        "mal.Anime": {
+            "type": "object",
+            "properties": {
+                "id": {
+                    "description": "This will be populated from the DB later",
+                    "type": "integer"
+                },
+                "image_url": {
+                    "type": "string"
+                },
+                "mal_id": {
+                    "type": "integer"
+                },
+                "title": {
+                    "type": "string"
+                },
+                "title_english": {
+                    "type": "string"
+                },
+                "title_japanese": {
+                    "type": "string"
+                },
+                "title_synonyms": {
+                    "type": "array",
+                    "items": {
+                        "type": "string"
+                    }
+                }
+            }
         }
     },
     "securityDefinitions": {
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index a74974a..8463d57 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -40,6 +40,26 @@ definitions:
       refresh_token:
         type: string
     type: object
+  mal.Anime:
+    properties:
+      id:
+        description: This will be populated from the DB later
+        type: integer
+      image_url:
+        type: string
+      mal_id:
+        type: integer
+      title:
+        type: string
+      title_english:
+        type: string
+      title_japanese:
+        type: string
+      title_synonyms:
+        items:
+          type: string
+        type: array
+    type: object
 externalDocs:
   description: OpenAPI
   url: https://swagger.io/resources/open-api/
diff --git a/go.mod b/go.mod
index 7ecbf6e..373fb09 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.22.3
 
 require (
 	github.com/cenkalti/backoff/v4 v4.3.0
+	github.com/darenliang/jikan-go v1.2.3
 	github.com/golang-jwt/jwt v3.2.2+incompatible
 	github.com/joho/godotenv v1.5.1
 	github.com/labstack/echo/v4 v4.12.0
diff --git a/go.sum b/go.sum
index 11cddbe..169dfc0 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,8 @@ github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZx
 github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/darenliang/jikan-go v1.2.3 h1:Nw6ykJU47QW3rwiIBWHyy1cBNM1Cxsz0AVCdqIN278A=
+github.com/darenliang/jikan-go v1.2.3/go.mod h1:rv7ksvNqc1b0UK7mf1Uc3swPToJXd9EZQLz5C38jk9Q=
 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=
diff --git a/mal/anime.go b/mal/anime.go
new file mode 100644
index 0000000..6ea2c21
--- /dev/null
+++ b/mal/anime.go
@@ -0,0 +1,11 @@
+package mal
+
+type Anime struct {
+	ID            int      `json:"id"` // This will be populated from the DB later
+	MalID         int      `json:"mal_id"`
+	Title         string   `json:"title"`
+	TitleEnglish  string   `json:"title_english"`
+	TitleJapanese string   `json:"title_japanese"`
+	TitleSynonyms []string `json:"title_synonyms"`
+	ImageUrl      string   `json:"image_url"`
+}
diff --git a/mal/client.go b/mal/client.go
new file mode 100644
index 0000000..82c4f9e
--- /dev/null
+++ b/mal/client.go
@@ -0,0 +1,49 @@
+package mal
+
+import (
+	"github.com/darenliang/jikan-go"
+	"net/url"
+)
+
+type IClient interface {
+	SearchAnime(query string) ([]Anime, error)
+}
+
+type Client struct {
+	Jikan_GetAnimeSearch func(query url.Values) (*jikan.AnimeSearch, error)
+}
+
+func NewClient() IClient {
+	return &Client{
+		Jikan_GetAnimeSearch: jikan.GetAnimeSearch,
+	}
+}
+
+func (c *Client) SearchAnime(query string) ([]Anime, error) {
+	jikanQuery := url.Values{}
+	jikanQuery.Set("q", query)
+	jikanQuery.Set("type", "tv")
+
+	searchResult, err := c.Jikan_GetAnimeSearch(jikanQuery)
+	if err != nil {
+		return nil, err
+	}
+
+	animes := make([]Anime, len(searchResult.Data))
+	for i, animeBase := range searchResult.Data {
+		animes[i] = animeBaseToAnime(animeBase)
+	}
+
+	return animes, nil
+}
+
+func animeBaseToAnime(base jikan.AnimeBase) Anime {
+	return Anime{
+		MalID:         base.MalId,
+		Title:         base.Title,
+		TitleEnglish:  base.TitleEnglish,
+		TitleJapanese: base.TitleJapanese,
+		TitleSynonyms: base.TitleSynonyms,
+		ImageUrl:      base.Images.Jpg.ImageUrl,
+	}
+}
diff --git a/mal/client_test.go b/mal/client_test.go
new file mode 100644
index 0000000..3fc7624
--- /dev/null
+++ b/mal/client_test.go
@@ -0,0 +1,83 @@
+package mal_test
+
+import (
+	"fmt"
+	"github.com/darenliang/jikan-go"
+	"github.com/stretchr/testify/suite"
+	"net/url"
+	"testing"
+
+	"steamshard.net/oppai-api/mal"
+)
+
+type MalClientTestSuite struct {
+	suite.Suite
+
+	subject mal.IClient
+}
+
+type MockJikanImage struct {
+	ImageUrl      string `json:"image_url"`
+	SmallImageUrl string `json:"small_image_url"`
+	LargeImageUrl string `json:"large_image_url"`
+}
+
+func (suite *MalClientTestSuite) SetupTest() {
+	suite.subject = mal.NewClient()
+
+	suite.subject.(*mal.Client).Jikan_GetAnimeSearch = func(query url.Values) (*jikan.AnimeSearch, error) {
+		return &jikan.AnimeSearch{
+			Data: []jikan.AnimeBase{
+				{
+					MalId:         48736,
+					Title:         "Sono Bisque Doll wa Koi wo Suru",
+					TitleEnglish:  "My Dress-Up Darling",
+					TitleJapanese: "その着せ替え人形は恋をする   ",
+					TitleSynonyms: []string{"Sono Kisekae Ningyou wa Koi wo Suru", "KiseKoi"},
+					Images: jikan.Images3{
+						Jpg: MockJikanImage{
+							ImageUrl:      "https://example.local/url.jpeg",
+							SmallImageUrl: "https://example.local/small.jpeg",
+							LargeImageUrl: "https://example.local/large.jpeg",
+						},
+						Webp: MockJikanImage{
+							ImageUrl:      "https://example.local/url.webp",
+							SmallImageUrl: "https://example.local/small.jpeg",
+							LargeImageUrl: "https://example.local/large.jpeg",
+						},
+					},
+				},
+			},
+		}, nil
+	}
+}
+
+func (suite *MalClientTestSuite) TestSearchAnime() {
+	animes, err := suite.subject.SearchAnime("Sono Bisque Doll wa Koi wo Suru")
+
+	suite.NoError(err)
+	suite.Len(animes, 1)
+
+	anime := animes[0]
+	suite.Equal(48736, anime.MalID)
+	suite.Equal("Sono Bisque Doll wa Koi wo Suru", anime.Title)
+	suite.Equal("My Dress-Up Darling", anime.TitleEnglish)
+	suite.Equal("その着せ替え人形は恋をする   ", anime.TitleJapanese)
+	suite.Equal([]string{"Sono Kisekae Ningyou wa Koi wo Suru", "KiseKoi"}, anime.TitleSynonyms)
+	suite.Equal("https://example.local/url.jpeg", anime.ImageUrl)
+}
+
+func (suite *MalClientTestSuite) TestSearchAnimeError() {
+	suite.subject.(*mal.Client).Jikan_GetAnimeSearch = func(query url.Values) (*jikan.AnimeSearch, error) {
+		return nil, fmt.Errorf("error")
+	}
+
+	animes, err := suite.subject.SearchAnime("Sono Bisque Doll wa Koi wo Suru")
+
+	suite.Error(err)
+	suite.Nil(animes)
+}
+
+func TestMalClientTestSuite(t *testing.T) {
+	suite.Run(t, new(MalClientTestSuite))
+}
diff --git a/mocks/mal_client.go b/mocks/mal_client.go
new file mode 100644
index 0000000..0d8800b
--- /dev/null
+++ b/mocks/mal_client.go
@@ -0,0 +1,16 @@
+package mocks
+
+import (
+	"github.com/stretchr/testify/mock"
+
+	"steamshard.net/oppai-api/mal"
+)
+
+type MockMalClient struct {
+	mock.Mock
+}
+
+func (m *MockMalClient) SearchAnime(query string) ([]mal.Anime, error) {
+	args := m.Called(query)
+	return args.Get(0).([]mal.Anime), args.Error(1)
+}
-- 
GitLab