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