From 1e7249b18efa53b6816de1b1e6193521c7b8ea90 Mon Sep 17 00:00:00 2001 From: Maxim Lebedev Date: Tue, 5 Oct 2021 00:50:00 +0500 Subject: [PATCH] :sparkles: Added a profile module to retrieve a profile from 3rd-party providers * Added repository interface * Added GitHub provider * Added Mastodon provider --- internal/profile/repository.go | 13 ++++ .../repository/github/github_profile.go | 66 +++++++++++++++++++ .../repository/github/github_profile_test.go | 43 ++++++++++++ .../repository/mastodon/mastodon_profile.go | 66 +++++++++++++++++++ .../mastodon/mastodon_profile_test.go | 45 +++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 internal/profile/repository.go create mode 100644 internal/profile/repository/github/github_profile.go create mode 100644 internal/profile/repository/github/github_profile_test.go create mode 100644 internal/profile/repository/mastodon/mastodon_profile.go create mode 100644 internal/profile/repository/mastodon/mastodon_profile_test.go diff --git a/internal/profile/repository.go b/internal/profile/repository.go new file mode 100644 index 0000000..b869aa5 --- /dev/null +++ b/internal/profile/repository.go @@ -0,0 +1,13 @@ +package profile + +import ( + "context" + + "golang.org/x/oauth2" + + "source.toby3d.me/website/oauth/internal/domain" +) + +type Repository interface { + Get(ctx context.Context, token oauth2.Token) (*domain.Profile, error) +} diff --git a/internal/profile/repository/github/github_profile.go b/internal/profile/repository/github/github_profile.go new file mode 100644 index 0000000..9217f7f --- /dev/null +++ b/internal/profile/repository/github/github_profile.go @@ -0,0 +1,66 @@ +package github + +import ( + "context" + + json "github.com/goccy/go-json" + "github.com/pkg/errors" + http "github.com/valyala/fasthttp" + "golang.org/x/oauth2" + + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/profile" +) + +type ( + //nolint: tagliatelle + Response struct { + Name string `json:"name"` + Blog string `json:"blog"` + AvatarURL string `json:"avatar_url"` + Email string `json:"email"` + } + + githubProfileRepository struct { + request *http.Request + client *http.Client + } +) + +func NewGitHubProfileRepository(client *http.Client) profile.Repository { + req := http.AcquireRequest() + req.SetRequestURI("https://api.github.com/user") + req.Header.SetMethod(http.MethodGet) + req.Header.Set(http.HeaderAccept, "application/vnd.github.v3+json") + + return &githubProfileRepository{ + request: req, + client: client, + } +} + +func (repo *githubProfileRepository) Get(ctx context.Context, token oauth2.Token) (*domain.Profile, error) { + req := http.AcquireRequest() + defer http.ReleaseRequest(req) + repo.request.CopyTo(req) + req.Header.Set(http.HeaderAuthorization, token.TokenType+" "+token.AccessToken) + + resp := http.AcquireResponse() + defer http.ReleaseResponse(resp) + + if err := repo.client.Do(req, resp); err != nil { + return nil, errors.Wrap(err, "failed to fetch authenticated user") + } + + result := new(Response) + if err := json.Unmarshal(resp.Body(), result); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal GitHub response") + } + + return &domain.Profile{ + Name: result.Name, + URL: result.Blog, + Photo: result.AvatarURL, + Email: result.Email, + }, nil +} diff --git a/internal/profile/repository/github/github_profile_test.go b/internal/profile/repository/github/github_profile_test.go new file mode 100644 index 0000000..acb3a03 --- /dev/null +++ b/internal/profile/repository/github/github_profile_test.go @@ -0,0 +1,43 @@ +package github_test + +import ( + "context" + "testing" + "time" + + json "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + http "github.com/valyala/fasthttp" + "golang.org/x/oauth2" + + "source.toby3d.me/website/oauth/internal/common" + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/profile/repository/github" + "source.toby3d.me/website/oauth/internal/util" +) + +func TestGet(t *testing.T) { + t.Parallel() + + p := domain.TestProfile(t) + client, _, cleanup := util.TestServe(t, func(ctx *http.RequestCtx) { + ctx.SetStatusCode(http.StatusOK) + ctx.SetContentType(common.MIMEApplicationJSON) + _ = json.NewEncoder(ctx).Encode(&github.Response{ + Name: p.Name, + Blog: p.URL, + AvatarURL: p.Photo, + Email: p.Email, + }) + }) + t.Cleanup(cleanup) + + result, err := github.NewGitHubProfileRepository(client).Get(context.TODO(), oauth2.Token{ + AccessToken: "hackme", + TokenType: "Bearer", + RefreshToken: "", + Expiry: time.Time{}, + }) + assert.NoError(t, err) + assert.Equal(t, p, result) +} diff --git a/internal/profile/repository/mastodon/mastodon_profile.go b/internal/profile/repository/mastodon/mastodon_profile.go new file mode 100644 index 0000000..35212f8 --- /dev/null +++ b/internal/profile/repository/mastodon/mastodon_profile.go @@ -0,0 +1,66 @@ +package mastodon + +import ( + "context" + "path" + + json "github.com/goccy/go-json" + "github.com/pkg/errors" + http "github.com/valyala/fasthttp" + "golang.org/x/oauth2" + + "source.toby3d.me/website/oauth/internal/common" + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/profile" +) + +type ( + Response struct { + DisplayName string `json:"display_name"` + Avatar string `json:"avatar"` + URL string `json:"url"` + } + + mastodonProfileRepository struct { + request *http.Request + client *http.Client + } +) + +func NewMastodonProfileRepository(client *http.Client, baseURL string) profile.Repository { + req := http.AcquireRequest() + req.SetRequestURI(baseURL) + req.URI().SetPath(path.Join("api", "v1", "accounts", "verify_credentials")) + req.Header.SetMethod(http.MethodGet) + req.Header.Set(http.HeaderAccept, common.MIMEApplicationJSON) + + return &mastodonProfileRepository{ + request: req, + client: client, + } +} + +func (repo *mastodonProfileRepository) Get(ctx context.Context, token oauth2.Token) (*domain.Profile, error) { + req := http.AcquireRequest() + defer http.ReleaseRequest(req) + repo.request.CopyTo(req) + req.Header.Set(http.HeaderAuthorization, token.TokenType+" "+token.AccessToken) + + resp := http.AcquireResponse() + defer http.ReleaseResponse(resp) + + if err := repo.client.Do(req, resp); err != nil { + return nil, errors.Wrap(err, "failed to fetch authenticated user") + } + + result := new(Response) + if err := json.Unmarshal(resp.Body(), result); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal GitHub response") + } + + return &domain.Profile{ + Name: result.DisplayName, + URL: result.URL, + Photo: result.Avatar, + }, nil +} diff --git a/internal/profile/repository/mastodon/mastodon_profile_test.go b/internal/profile/repository/mastodon/mastodon_profile_test.go new file mode 100644 index 0000000..d2622bf --- /dev/null +++ b/internal/profile/repository/mastodon/mastodon_profile_test.go @@ -0,0 +1,45 @@ +package mastodon_test + +import ( + "context" + "testing" + "time" + + json "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" + http "github.com/valyala/fasthttp" + "golang.org/x/oauth2" + + "source.toby3d.me/website/oauth/internal/common" + "source.toby3d.me/website/oauth/internal/domain" + "source.toby3d.me/website/oauth/internal/profile/repository/mastodon" + "source.toby3d.me/website/oauth/internal/util" +) + +func TestGet(t *testing.T) { + t.Parallel() + + p := domain.TestProfile(t) + p.Email = "" // WARN(toby3d): Mastodon does not provide user email information + + client, _, cleanup := util.TestServe(t, func(ctx *http.RequestCtx) { + ctx.SetStatusCode(http.StatusOK) + ctx.SetContentType(common.MIMEApplicationJSON) + _ = json.NewEncoder(ctx).Encode(&mastodon.Response{ + DisplayName: p.Name, + Avatar: p.Photo, + URL: p.URL, + }) + }) + t.Cleanup(cleanup) + + result, err := mastodon.NewMastodonProfileRepository(client, "https://mstdn.io/"). + Get(context.TODO(), oauth2.Token{ + AccessToken: "hackme", + TokenType: "Bearer", + RefreshToken: "", + Expiry: time.Time{}, + }) + assert.NoError(t, err) + assert.Equal(t, p, result) +}