diff --git a/CHANGELOG.md b/CHANGELOG.md index e4af44d6..dec45bec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Dev: Improve Twitch.tv clip tests. (#283) - Dev: Improve YouTube tests. (#284) - Dev: Resolver Check now returns a context. (#287) +- Dev: Improve Wikipedia tests. (#286) ## 1.2.3 diff --git a/internal/resolvers/wikipedia/article_context.go b/internal/resolvers/wikipedia/article_context.go new file mode 100644 index 00000000..c73c7f48 --- /dev/null +++ b/internal/resolvers/wikipedia/article_context.go @@ -0,0 +1,35 @@ +package wikipedia + +import ( + "context" + "errors" +) + +type contextKey string + +var ( + contextLocaleCode = contextKey("localeCode") + contextArticleID = contextKey("articleID") + + errMissingArticleValues = errors.New("missing article values in context") +) + +func contextWithArticleValues(ctx context.Context, localeCode, articleID string) context.Context { + ctx = context.WithValue(ctx, contextLocaleCode, localeCode) + ctx = context.WithValue(ctx, contextArticleID, articleID) + return ctx +} + +func articleValuesFromContext(ctx context.Context) (string, string, error) { + articleID, ok := ctx.Value(contextArticleID).(string) + if !ok { + return "", "", errMissingArticleValues + } + + localeCode, ok := ctx.Value(contextLocaleCode).(string) + if !ok { + return "", "", errMissingArticleValues + } + + return localeCode, articleID, nil +} diff --git a/internal/resolvers/wikipedia/article_loader.go b/internal/resolvers/wikipedia/article_loader.go index 1a4c12af..6b2b3021 100644 --- a/internal/resolvers/wikipedia/article_loader.go +++ b/internal/resolvers/wikipedia/article_loader.go @@ -5,57 +5,54 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" "time" "github.com/Chatterino/api/internal/logger" - "github.com/Chatterino/api/pkg/cache" "github.com/Chatterino/api/pkg/humanize" "github.com/Chatterino/api/pkg/resolver" ) type ArticleLoader struct { - endpointURL string + // the apiURL format must consist of 2 %s, first being region second being article + apiURL string } -func (l *ArticleLoader) getPageInfo(ctx context.Context, urlString string) (*wikipediaTooltipData, error) { - u, err := url.Parse(urlString) - if err != nil { - return nil, err - } +func (l *ArticleLoader) Load(ctx context.Context, unused string, r *http.Request) (*resolver.Response, time.Duration, error) { + log := logger.FromContext(ctx) // Since the Wikipedia API is locale-dependant, we need the locale code. // For example, if you want to resolve a de.wikipedia.org link, you need // to ping the DE API endpoint. - localeMatch := localeRegexp.FindStringSubmatch(u.Hostname()) - if len(localeMatch) != 2 { - return nil, errLocaleMatch - } - - localeCode := localeMatch[1] - - titleMatch := titleRegexp.FindStringSubmatch(u.Path) - if len(titleMatch) != 2 { - return nil, errTitleMatch + // If no locale is specified in the given URL, we will assume it's the english wiki article + localeCode, articleID, err := articleValuesFromContext(ctx) + if err != nil { + return nil, resolver.NoSpecialDur, err } - canonicalName := titleMatch[1] + log.Debugw("[Wikipedia] GET", + "localeCode", localeCode, + "articleID", articleID, + ) - requestURL := fmt.Sprintf(l.endpointURL, localeCode, canonicalName) + requestURL := fmt.Sprintf(l.apiURL, localeCode, articleID) resp, err := resolver.RequestGET(ctx, requestURL) if err != nil { - return nil, err + return nil, resolver.NoSpecialDur, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("bad status: %d", resp.StatusCode) + return &resolver.Response{ + Status: http.StatusNotFound, + Message: "No Wikipedia article found", + }, resolver.NoSpecialDur, nil + // return nil, fmt.Errorf("bad status: %d", resp.StatusCode) } var pageInfo *wikipediaAPIResponse if err = json.NewDecoder(resp.Body).Decode(&pageInfo); err != nil { - return nil, err + return resolver.Errorf("Wikipedia API unmarshal JSON error: %s", err) } // Transform API response into our tooltip model for Wikipedia links @@ -76,26 +73,5 @@ func (l *ArticleLoader) getPageInfo(ctx context.Context, urlString string) (*wik tooltipData.ThumbnailURL = pageInfo.Thumbnail.URL } - return tooltipData, nil -} - -func (l *ArticleLoader) Load(ctx context.Context, urlString string, r *http.Request) (*resolver.Response, time.Duration, error) { - log := logger.FromContext(ctx) - - log.Debugw("[Wikipedia] GET", - "url", urlString, - ) - - tooltipData, err := l.getPageInfo(ctx, urlString) - - if err != nil { - log.Debugw("[Wikipedia] Unable to get page info", - "url", urlString, - "error", err, - ) - - return nil, cache.NoSpecialDur, resolver.ErrDontHandle - } - return buildTooltip(tooltipData) } diff --git a/internal/resolvers/wikipedia/article_loader_test.go b/internal/resolvers/wikipedia/article_loader_test.go index f12644c1..27126e70 100644 --- a/internal/resolvers/wikipedia/article_loader_test.go +++ b/internal/resolvers/wikipedia/article_loader_test.go @@ -2,52 +2,13 @@ package wikipedia import ( "context" - "encoding/json" "fmt" - "net/http" - "net/http/httptest" "net/url" "testing" - "github.com/Chatterino/api/internal/logger" - "github.com/Chatterino/api/pkg/utils" qt "github.com/frankban/quicktest" - "github.com/go-chi/chi/v5" ) -var ( - wikiData = map[string]*wikipediaAPIResponse{} -) - -func init() { - wikiData["en_test"] = &wikipediaAPIResponse{ - Titles: wikipediaAPITitles{ - Normalized: "Test title", - }, - Extract: "Test extract", - Thumbnail: nil, - Description: utils.StringPtr("Test description"), - } - - wikiData["en_test_html"] = &wikipediaAPIResponse{ - Titles: wikipediaAPITitles{ - Normalized: "Test title", - }, - Extract: "Test extract", - Thumbnail: nil, - Description: utils.StringPtr("Test description"), - } - - wikiData["en_test_no_description"] = &wikipediaAPIResponse{ - Titles: wikipediaAPITitles{ - Normalized: "Test title", - }, - Extract: "Test extract", - Thumbnail: nil, - Description: nil, - } -} - func testLoadAndUnescape(ctx context.Context, loader *ArticleLoader, c *qt.C, locale, page string) (cleanTooltip string) { urlString := fmt.Sprintf("https://%s.wikipedia.org/wiki/%s", locale, page) response, _, err := loader.Load(ctx, urlString, nil) @@ -62,65 +23,47 @@ func testLoadAndUnescape(ctx context.Context, loader *ArticleLoader, c *qt.C, lo } func TestLoad(t *testing.T) { - ctx := logger.OnContext(context.Background(), logger.NewTest()) - c := qt.New(t) - r := chi.NewRouter() - r.Get("/api/rest_v1/page/summary/{locale}/{page}", func(w http.ResponseWriter, r *http.Request) { - locale := chi.URLParam(r, "locale") - page := chi.URLParam(r, "page") - - var response *wikipediaAPIResponse - var ok bool + // ctx := logger.OnContext(context.Background(), logger.NewTest()) + // c := qt.New(t) + // ts := testServer() + // defer ts.Close() - if response, ok = wikiData[locale+"_"+page]; !ok { - http.Error(w, http.StatusText(404), 404) - return - } + // loader := &ArticleLoader{ + // apiURL: ts.URL + "/api/rest_v1/page/summary/%s/%s", + // } - b, _ := json.Marshal(&response) - - w.Header().Set("Content-Type", "application/json") - w.Write(b) - }) - ts := httptest.NewServer(r) - defer ts.Close() - - loader := &ArticleLoader{ - endpointURL: ts.URL + "/api/rest_v1/page/summary/%s/%s", - } - - c.Run("Normal page", func(c *qt.C) { - const locale = "en" - const page = "test" + // c.Run("Normal page", func(c *qt.C) { + // const locale = "en" + // const page = "test" - const expectedTooltip = `
Test title • Test description
Test extract
` + // const expectedTooltip = `
Test title • Test description
Test extract
` - cleanTooltip := testLoadAndUnescape(ctx, loader, c, locale, page) + // cleanTooltip := testLoadAndUnescape(ctx, loader, c, locale, page) - c.Assert(cleanTooltip, qt.Equals, expectedTooltip) - }) + // c.Assert(cleanTooltip, qt.Equals, expectedTooltip) + // }) - c.Run("Normal page (HTML)", func(c *qt.C) { - const locale = "en" - const page = "test_html" + // c.Run("Normal page (HTML)", func(c *qt.C) { + // const locale = "en" + // const page = "test_html" - const expectedTooltip = `
<b>Test title</b> • <b>Test description</b>
<b>Test extract</b>
` + // const expectedTooltip = `
<b>Test title</b> • <b>Test description</b>
<b>Test extract</b>
` - cleanTooltip := testLoadAndUnescape(ctx, loader, c, locale, page) + // cleanTooltip := testLoadAndUnescape(ctx, loader, c, locale, page) - c.Assert(cleanTooltip, qt.Equals, expectedTooltip) - }) + // c.Assert(cleanTooltip, qt.Equals, expectedTooltip) + // }) - c.Run("Normal page (No description)", func(c *qt.C) { - const locale = "en" - const page = "test_no_description" + // c.Run("Normal page (No description)", func(c *qt.C) { + // const locale = "en" + // const page = "test_no_description" - const expectedTooltip = `
Test title
Test extract
` + // const expectedTooltip = `
Test title
Test extract
` - cleanTooltip := testLoadAndUnescape(ctx, loader, c, locale, page) + // cleanTooltip := testLoadAndUnescape(ctx, loader, c, locale, page) - c.Assert(cleanTooltip, qt.Equals, expectedTooltip) - }) + // c.Assert(cleanTooltip, qt.Equals, expectedTooltip) + // }) // c.Run("Nonexistant page", func(c *qt.C) { // const locale = "en" diff --git a/internal/resolvers/wikipedia/article_resolver.go b/internal/resolvers/wikipedia/article_resolver.go index 44185a05..d3abb1b5 100644 --- a/internal/resolvers/wikipedia/article_resolver.go +++ b/internal/resolvers/wikipedia/article_resolver.go @@ -18,11 +18,47 @@ type ArticleResolver struct { articleCache cache.Cache } -func (r *ArticleResolver) Check(ctx context.Context, url *url.URL) (context.Context, bool) { - isWikipedia := utils.IsSubdomainOf(url, "wikipedia.org") - isWikiArticle := strings.HasPrefix(url.Path, "/wiki/") +// getLocaleCode returns the locale code figured out from the url hostname, or "en" if none is found +func (r *ArticleResolver) getLocaleCode(u *url.URL) string { + localeMatch := localeRegexp.FindStringSubmatch(u.Hostname()) + if len(localeMatch) != 2 { + return "en" + } + + return localeMatch[1] +} + +// getArticleID returns the locale code figured out from the url hostname, or "en" if none is found +func (r *ArticleResolver) getArticleID(u *url.URL) (string, error) { + titleMatch := titleRegexp.FindStringSubmatch(u.Path) + if len(titleMatch) != 2 { + return "", errTitleMatch + } + + return titleMatch[1], nil +} + +func (r *ArticleResolver) Check(ctx context.Context, u *url.URL) (context.Context, bool) { + if !utils.IsSubdomainOf(u, "wikipedia.org") { + return ctx, false + } + + if !strings.HasPrefix(u.Path, "/wiki/") { + return ctx, false + } + + // Load locale code & article ID + localeCode := r.getLocaleCode(u) + articleID, err := r.getArticleID(u) + if err != nil { + return ctx, false + } + + ctx = contextWithArticleValues(ctx, localeCode, articleID) + + // Attach locale code & article ID to context - return ctx, isWikipedia && isWikiArticle + return ctx, true } func (r *ArticleResolver) Run(ctx context.Context, url *url.URL, req *http.Request) ([]byte, error) { @@ -33,10 +69,9 @@ func (r *ArticleResolver) Name() string { return "wikipedia:article" } -func NewArticleResolver(ctx context.Context, cfg config.APIConfig, pool db.Pool) *ArticleResolver { - const endpointURL = "https://%s.wikipedia.org/api/rest_v1/page/summary/%s?redirect=false" +func NewArticleResolver(ctx context.Context, cfg config.APIConfig, pool db.Pool, apiURL string) *ArticleResolver { articleLoader := &ArticleLoader{ - endpointURL: endpointURL, + apiURL: apiURL, } r := &ArticleResolver{ diff --git a/internal/resolvers/wikipedia/article_resolver_test.go b/internal/resolvers/wikipedia/article_resolver_test.go new file mode 100644 index 00000000..bd00a5fb --- /dev/null +++ b/internal/resolvers/wikipedia/article_resolver_test.go @@ -0,0 +1,188 @@ +package wikipedia + +import ( + "context" + "net/url" + "testing" + + "github.com/Chatterino/api/internal/logger" + "github.com/Chatterino/api/pkg/config" + "github.com/Chatterino/api/pkg/utils" + qt "github.com/frankban/quicktest" + "github.com/jackc/pgx/v4" + "github.com/pashagolub/pgxmock" +) + +func TestArticleResolver(t *testing.T) { + ctx := logger.OnContext(context.Background(), logger.NewTest()) + c := qt.New(t) + + pool, _ := pgxmock.NewPool() + + cfg := config.APIConfig{} + ts := testServer() + defer ts.Close() + apiURL := ts.URL + "/api/rest_v1/page/summary/%s/%s" + + r := NewArticleResolver(ctx, cfg, pool, apiURL) + + c.Assert(r, qt.IsNotNil) + + c.Run("Name", func(c *qt.C) { + c.Assert(r.Name(), qt.Equals, "wikipedia:article") + }) + + c.Run("Check", func(c *qt.C) { + type checkTest struct { + label string + input *url.URL + expected bool + } + + tests := []checkTest{ + { + label: "Matching domain, no WWW", + input: utils.MustParseURL("https://wikipedia.org/wiki/ArticleID"), + expected: true, + }, + { + label: "Matching domain, WWW", + input: utils.MustParseURL("https://www.wikipedia.org/wiki/ArticleID"), + expected: true, + }, + { + label: "Matching domain, English", + input: utils.MustParseURL("https://en.wikipedia.org/wiki/ArticleID"), + expected: true, + }, + { + label: "Matching domain, German", + input: utils.MustParseURL("https://de.wikipedia.org/wiki/Gurke"), + expected: true, + }, + { + label: "Matching domain, missing path", + input: utils.MustParseURL("https://de.wikipedia.org/wiki/"), + expected: false, + }, + { + label: "Matching domain, non-matching path", + input: utils.MustParseURL("https://wikipedia.org/bad"), + expected: false, + }, + { + label: "Non-matching domain", + input: utils.MustParseURL("https://example.com/wiki/ArticleID"), + expected: false, + }, + } + + for _, test := range tests { + c.Run(test.label, func(c *qt.C) { + _, output := r.Check(ctx, test.input) + c.Assert(output, qt.Equals, test.expected) + }) + } + }) + + c.Run("Run", func(c *qt.C) { + c.Run("Context error", func(c *qt.C) { + type runTest struct { + label string + inputURL *url.URL + inputLocaleCode *string + inputArticleID *string + expectedError error + rowsReturned int + } + + tests := []runTest{ + { + label: "Missing locale code", + inputURL: utils.MustParseURL("https://wikipedia.org/wiki/404"), + inputLocaleCode: nil, + inputArticleID: utils.StringPtr("404"), + expectedError: errMissingArticleValues, + }, + { + label: "Missing article ID", + inputURL: utils.MustParseURL("https://en.wikipedia.org/wiki/"), + inputLocaleCode: utils.StringPtr("en"), + inputArticleID: nil, + expectedError: errMissingArticleValues, + }, + } + + const q = `SELECT value FROM cache WHERE key=$1` + + for _, test := range tests { + c.Run(test.label, func(c *qt.C) { + pool.ExpectQuery("SELECT").WillReturnError(pgx.ErrNoRows) + ctx := ctx + if test.inputLocaleCode != nil { + ctx = context.WithValue(ctx, contextLocaleCode, *test.inputLocaleCode) + } + if test.inputArticleID != nil { + ctx = context.WithValue(ctx, contextArticleID, *test.inputArticleID) + } + outputBytes, outputError := r.Run(ctx, test.inputURL, nil) + c.Assert(outputError, qt.Equals, test.expectedError) + c.Assert(outputBytes, qt.IsNil) + }) + } + }) + + c.Run("Not cached", func(c *qt.C) { + type runTest struct { + label string + inputURL *url.URL + expectedBytes []byte + rowsReturned int + } + + tests := []runTest{ + { + label: "404", + inputURL: utils.MustParseURL("https://wikipedia.org/wiki/404"), + expectedBytes: []byte(`{"status":404,"message":"No Wikipedia article found"}`), + }, + { + label: "Normal page (HTML)", + inputURL: utils.MustParseURL("https://wikipedia.org/wiki/test_html"), + expectedBytes: []byte(`{"status":200,"tooltip":"%3Cdiv%20style=%22text-align:%20left%3B%22%3E%3Cb%3E\u0026lt%3Bb\u0026gt%3BTest%20title\u0026lt%3B%2Fb\u0026gt%3B\u0026nbsp%3B%E2%80%A2\u0026nbsp%3B\u0026lt%3Bb\u0026gt%3BTest%20description\u0026lt%3B%2Fb\u0026gt%3B%3C%2Fb%3E%3Cbr%3E\u0026lt%3Bb\u0026gt%3BTest%20extract\u0026lt%3B%2Fb\u0026gt%3B%3C%2Fdiv%3E"}`), + }, + { + label: "Normal page (No description)", + inputURL: utils.MustParseURL("https://wikipedia.org/wiki/test_no_description"), + expectedBytes: []byte(`{"status":200,"tooltip":"%3Cdiv%20style=%22text-align:%20left%3B%22%3E%3Cb%3ETest%20title%3C%2Fb%3E%3Cbr%3ETest%20extract%3C%2Fdiv%3E"}`), + }, + { + label: "Normal page (with thumbnail)", + inputURL: utils.MustParseURL("https://wikipedia.org/wiki/thumbnail"), + expectedBytes: []byte(`{"status":200,"thumbnail":"https://example.com/thumbnail.png","tooltip":"%3Cdiv%20style=%22text-align:%20left%3B%22%3E%3Cb%3ETest%20title%3C%2Fb%3E%3Cbr%3ETest%20extract%3C%2Fdiv%3E"}`), + }, + { + label: "Bad JSON", + inputURL: utils.MustParseURL("https://en.wikipedia.org/wiki/badjson"), + expectedBytes: []byte(`{"status":500,"message":"Wikipedia API unmarshal JSON error: invalid character \u0026#39;x\u0026#39; looking for beginning of value"}`), + }, + } + + const q = `SELECT value FROM cache WHERE key=$1` + + for _, test := range tests { + c.Run(test.label, func(c *qt.C) { + pool.ExpectQuery("SELECT").WillReturnError(pgx.ErrNoRows) + pool.ExpectExec("INSERT INTO cache"). + WithArgs(pgxmock.AnyArg(), test.expectedBytes, pgxmock.AnyArg()). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + ctx, checkResult := r.Check(ctx, test.inputURL) + c.Assert(checkResult, qt.IsTrue) + outputBytes, outputError := r.Run(ctx, test.inputURL, nil) + c.Assert(outputError, qt.IsNil) + c.Assert(outputBytes, qt.DeepEquals, test.expectedBytes) + }) + } + }) + }) +} diff --git a/internal/resolvers/wikipedia/data_test.go b/internal/resolvers/wikipedia/data_test.go new file mode 100644 index 00000000..1b17de58 --- /dev/null +++ b/internal/resolvers/wikipedia/data_test.go @@ -0,0 +1,82 @@ +package wikipedia + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/Chatterino/api/pkg/utils" + "github.com/go-chi/chi/v5" +) + +var ( + wikiData = map[string]*wikipediaAPIResponse{} +) + +func init() { + wikiData["en_test"] = &wikipediaAPIResponse{ + Titles: wikipediaAPITitles{ + Normalized: "Test title", + }, + Extract: "Test extract", + Thumbnail: nil, + Description: utils.StringPtr("Test description"), + } + + wikiData["en_test_html"] = &wikipediaAPIResponse{ + Titles: wikipediaAPITitles{ + Normalized: "Test title", + }, + Extract: "Test extract", + Thumbnail: nil, + Description: utils.StringPtr("Test description"), + } + + wikiData["en_test_no_description"] = &wikipediaAPIResponse{ + Titles: wikipediaAPITitles{ + Normalized: "Test title", + }, + Extract: "Test extract", + Thumbnail: nil, + Description: nil, + } + + wikiData["en_thumbnail"] = &wikipediaAPIResponse{ + Titles: wikipediaAPITitles{ + Normalized: "Test title", + }, + Extract: "Test extract", + Thumbnail: &wikipediaAPIThumbnail{ + URL: "https://example.com/thumbnail.png", + }, + Description: nil, + } +} + +func testServer() *httptest.Server { + r := chi.NewRouter() + r.Get("/api/rest_v1/page/summary/{locale}/{page}", func(w http.ResponseWriter, r *http.Request) { + locale := chi.URLParam(r, "locale") + page := chi.URLParam(r, "page") + + var response *wikipediaAPIResponse + var ok bool + + w.Header().Set("Content-Type", "application/json") + + if page == "badjson" { + w.Write([]byte(`xD`)) + return + } + + if response, ok = wikiData[locale+"_"+page]; !ok { + http.Error(w, http.StatusText(404), 404) + return + } + + b, _ := json.Marshal(&response) + + w.Write(b) + }) + return httptest.NewServer(r) +} diff --git a/internal/resolvers/wikipedia/helpers.go b/internal/resolvers/wikipedia/helpers.go index 67ae64dc..c67c92dd 100644 --- a/internal/resolvers/wikipedia/helpers.go +++ b/internal/resolvers/wikipedia/helpers.go @@ -14,10 +14,7 @@ func buildTooltip(pageInfo *wikipediaTooltipData) (*resolver.Response, time.Dura var tooltip bytes.Buffer if err := wikipediaTooltipTemplate.Execute(&tooltip, pageInfo); err != nil { - return &resolver.Response{ - Status: http.StatusInternalServerError, - Message: "Wikipedia template error: " + resolver.CleanResponse(err.Error()), - }, cache.NoSpecialDur, nil + return resolver.Errorf("Wikipedia template error: %s", err.Error()) } return &resolver.Response{ diff --git a/internal/resolvers/wikipedia/initialize.go b/internal/resolvers/wikipedia/initialize.go index 81d5e11d..3c4beeb3 100644 --- a/internal/resolvers/wikipedia/initialize.go +++ b/internal/resolvers/wikipedia/initialize.go @@ -22,5 +22,7 @@ var ( ) func Initialize(ctx context.Context, cfg config.APIConfig, pool db.Pool, resolvers *[]resolver.Resolver) { - *resolvers = append(*resolvers, NewArticleResolver(ctx, cfg, pool)) + const apiURL = "https://%s.wikipedia.org/api/rest_v1/page/summary/%s?redirect=false" + + *resolvers = append(*resolvers, NewArticleResolver(ctx, cfg, pool, apiURL)) } diff --git a/internal/resolvers/wikipedia/initialize_test.go b/internal/resolvers/wikipedia/initialize_test.go new file mode 100644 index 00000000..0d2c175c --- /dev/null +++ b/internal/resolvers/wikipedia/initialize_test.go @@ -0,0 +1,26 @@ +package wikipedia + +import ( + "context" + "testing" + + "github.com/Chatterino/api/internal/logger" + "github.com/Chatterino/api/pkg/config" + "github.com/Chatterino/api/pkg/resolver" + qt "github.com/frankban/quicktest" + "github.com/pashagolub/pgxmock" +) + +func TestInitialize(t *testing.T) { + ctx := logger.OnContext(context.Background(), logger.NewTest()) + c := qt.New(t) + + cfg := config.APIConfig{} + pool, err := pgxmock.NewPool() + c.Assert(err, qt.IsNil) + customResolvers := []resolver.Resolver{} + + c.Assert(customResolvers, qt.HasLen, 0) + Initialize(ctx, cfg, pool, &customResolvers) + c.Assert(customResolvers, qt.HasLen, 1) +}