From 7f45e62dceee7c37b194534222eaff4cd1d54826 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sun, 31 May 2026 19:01:26 +0200 Subject: [PATCH] refactor: extract generic graphql client --- integrations/playback/allanime/client.go | 110 +++++++++++++---------- pkg/graphql.go | 79 ++++++++++++++++ 2 files changed, 142 insertions(+), 47 deletions(-) create mode 100644 pkg/graphql.go diff --git a/integrations/playback/allanime/client.go b/integrations/playback/allanime/client.go index 2e9f7bc..833bac8 100644 --- a/integrations/playback/allanime/client.go +++ b/integrations/playback/allanime/client.go @@ -11,6 +11,7 @@ import ( "fmt" "io" "mal/internal/domain" + "mal/pkg" netutil "mal/pkg/net" "net/http" "net/url" @@ -65,60 +66,75 @@ func (c *AllAnimeProvider) Name() string { return "AllAnime" } -func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) { - // 1. Search for the show to get its AllAnime ID - graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) { - shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) { - edges { - _id - malId - name - } - } - }` +const searchQuery = `query( + $search: SearchInput + $translationType: VaildTranslationTypeEnumType + $limit: Int = 40 + $page: Int = 1 + $countryOrigin: VaildCountryOriginEnumType = ALL +) { + shows( + search: $search + limit: $limit + page: $page + translationType: $translationType + countryOrigin: $countryOrigin + ) { + edges { + _id + malId + name + } + } +}` - variables := map[string]any{ - "search": map[string]any{ - "allowAdult": false, - "allowUnknown": false, - "query": query, - }, - "limit": 40, - "page": 1, - "translationType": mode, - "countryOrigin": "ALL", +func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) { + type searchData struct { + Shows struct { + Edges []struct { + ID string `json:"_id"` + MalID string `json:"malId"` + Name string `json:"name"` + } `json:"edges"` + } `json:"shows"` } - result, err := c.graphqlRequest(ctx, graphqlQuery, variables) + type searchInput struct { + AllowAdult bool `json:"allowAdult"` + AllowUnknown bool `json:"allowUnknown"` + Query string `json:"query"` + } + + type searchVariables struct { + Search searchInput `json:"search"` + TranslationType string `json:"translationType"` + } + + vars := searchVariables{ + Search: searchInput{ + AllowAdult: false, + AllowUnknown: false, + Query: query, + }, + TranslationType: mode, + } + + data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{ + Headers: map[string]string{ + "Referer": allAnimeReferer, + "User-Agent": defaultUserAgent, + }, + BodyMax: netutil.MiB2, + }) if err != nil { return nil, err } - data, ok := result["data"].(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid search response") - } - - shows, ok := data["shows"].(map[string]any) - if !ok { - return nil, fmt.Errorf("invalid shows payload") - } - - edges, ok := shows["edges"].([]any) - if !ok { - return nil, fmt.Errorf("invalid search edges") - } - - out := make([]searchResult, 0, len(edges)) - for _, edge := range edges { - item, ok := edge.(map[string]any) - if !ok { - continue - } - - id, _ := item["_id"].(string) - malID, _ := item["malId"].(string) - name, _ := item["name"].(string) + out := make([]searchResult, 0, len(data.Shows.Edges)) + for _, edge := range data.Shows.Edges { + id := edge.ID + malID := edge.MalID + name := edge.Name if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil { name = unquoted } diff --git a/pkg/graphql.go b/pkg/graphql.go new file mode 100644 index 0000000..c1dac1d --- /dev/null +++ b/pkg/graphql.go @@ -0,0 +1,79 @@ +package graphql + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type Error struct { + Message string `json:"message"` +} + +type Response[T any] struct { + Data T `json:"data"` + Errors []Error `json:"errors"` +} + +type PostOptions struct { + Headers map[string]string + BodyMax int64 +} + +func Post[T any](ctx context.Context, httpClient *http.Client, url string, query string, variables any, opts PostOptions) (T, error) { + var zero T + + payload := map[string]any{ + "query": query, + "variables": variables, + } + + body, err := json.Marshal(payload) + if err != nil { + return zero, fmt.Errorf("graphql: marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return zero, fmt.Errorf("graphql: create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + for k, v := range opts.Headers { + req.Header.Set(k, v) + } + + resp, err := httpClient.Do(req) + if err != nil { + return zero, fmt.Errorf("graphql: execute request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + max := opts.BodyMax + if max <= 0 { + max = 2 << 20 + } + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, max)) + if err != nil { + return zero, fmt.Errorf("graphql: read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return zero, fmt.Errorf("graphql: status %d", resp.StatusCode) + } + + var parsed Response[T] + if err := json.Unmarshal(respBody, &parsed); err != nil { + return zero, fmt.Errorf("graphql: decode response: %w", err) + } + + if len(parsed.Errors) > 0 { + return zero, fmt.Errorf("graphql: %s", parsed.Errors[0].Message) + } + + return parsed.Data, nil +}