httpserver: Save Twitter user to store
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Rob Watson 2022-05-21 00:34:20 +02:00
parent 6be489c44f
commit 9767ad2331
10 changed files with 228 additions and 38 deletions

View File

@ -0,0 +1,49 @@
// Code generated by mockery v2.12.2. DO NOT EDIT.
package mocks
import (
testing "testing"
mock "github.com/stretchr/testify/mock"
twitter "git.netflux.io/rob/elon-eats-my-tweets/twitter"
)
// TwitterAPIClient is an autogenerated mock type for the APIClient type
type TwitterAPIClient struct {
mock.Mock
}
// GetMe provides a mock function with given fields:
func (_m *TwitterAPIClient) GetMe() (*twitter.User, error) {
ret := _m.Called()
var r0 *twitter.User
if rf, ok := ret.Get(0).(func() *twitter.User); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*twitter.User)
}
}
var r1 error
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewTwitterAPIClient creates a new instance of TwitterAPIClient. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations.
func NewTwitterAPIClient(t testing.TB) *TwitterAPIClient {
mock := &TwitterAPIClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@ -12,7 +12,7 @@ import (
type User struct {
ID uuid.UUID
TwitterID int32
TwitterID string
Username string
Name string
AccessToken string

View File

@ -11,16 +11,20 @@ import (
)
const createUser = `-- name: CreateUser :one
INSERT INTO users (twitter_id, username, name, access_token, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO users (twitter_id, username, name, access_token, refresh_token, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (twitter_id)
DO
UPDATE SET access_token = EXCLUDED.access_token, refresh_token = EXCLUDED.refresh_token, username = EXCLUDED.username, name = EXCLUDED.name, updated_at = EXCLUDED.updated_at
RETURNING id, twitter_id, username, name, access_token, refresh_token, delete_tweets_enabled, delete_tweets_num_per_iteration, created_at, updated_at
`
type CreateUserParams struct {
TwitterID int32
TwitterID string
Username string
Name string
AccessToken string
RefreshToken string
CreatedAt time.Time
UpdatedAt time.Time
}
@ -31,6 +35,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
arg.Username,
arg.Name,
arg.AccessToken,
arg.RefreshToken,
arg.CreatedAt,
arg.UpdatedAt,
)
@ -49,3 +54,25 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
)
return i, err
}
const getUserByTwitterID = `-- name: GetUserByTwitterID :one
SELECT id, twitter_id, username, name, access_token, refresh_token, delete_tweets_enabled, delete_tweets_num_per_iteration, created_at, updated_at FROM users WHERE twitter_id = $1
`
func (q *Queries) GetUserByTwitterID(ctx context.Context, twitterID string) (User, error) {
row := q.db.QueryRow(ctx, getUserByTwitterID, twitterID)
var i User
err := row.Scan(
&i.ID,
&i.TwitterID,
&i.Username,
&i.Name,
&i.AccessToken,
&i.RefreshToken,
&i.DeleteTweetsEnabled,
&i.DeleteTweetsNumPerIteration,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@ -5,10 +5,11 @@ package httpserver
import (
"context"
"html/template"
"io"
"net/http"
"time"
"git.netflux.io/rob/elon-eats-my-tweets/config"
"git.netflux.io/rob/elon-eats-my-tweets/generated/store"
"git.netflux.io/rob/elon-eats-my-tweets/twitter"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
@ -25,16 +26,21 @@ const (
pkceVerifierLen = 64
)
type TwitterAPIClient interface {
GetMe() (*twitter.User, error)
}
type handler struct {
templates *template.Template
store twitter.Store
twitterAPIClientFunc func(c *http.Client) TwitterAPIClient
oauth2Config *oauth2.Config
sessionStore sessions.Store
tokenGenerator TokenGenerator
logger *zap.SugaredLogger
}
func NewHandler(cfg config.Config, templates *template.Template, store twitter.Store, sessionStore sessions.Store, tokenGenerator TokenGenerator, logger *zap.Logger) http.Handler {
func NewHandler(cfg config.Config, templates *template.Template, store twitter.Store, twitterAPIClientFunc func(c *http.Client) TwitterAPIClient, sessionStore sessions.Store, tokenGenerator TokenGenerator, logger *zap.Logger) http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
@ -44,6 +50,7 @@ func NewHandler(cfg config.Config, templates *template.Template, store twitter.S
h := handler{
templates: templates,
store: store,
twitterAPIClientFunc: twitterAPIClientFunc,
oauth2Config: &oauth2.Config{
ClientID: cfg.Twitter.ClientID,
ClientSecret: cfg.Twitter.ClientSecret,
@ -139,20 +146,28 @@ func (h *handler) getCallback(w http.ResponseWriter, r *http.Request) {
return
}
client := h.oauth2Config.Client(context.Background(), token)
resp, err := client.Get("https://api.twitter.com/2/users/me")
twitterClient := h.twitterAPIClientFunc(h.oauth2Config.Client(context.Background(), token))
twitterUser, err := twitterClient.GetMe()
if err != nil {
h.logger.With("err", err).Error("error fetching user")
http.Error(w, "error fetching user", http.StatusInternalServerError)
h.logger.With("err", err).Error("error fetching user from twitter")
http.Error(w, "error validating user", http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// TODO: do something sensible
body, _ := io.ReadAll(resp.Body)
w.Header().Set("content-type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err = w.Write([]byte(body)); err != nil {
h.logger.With("err", err).Error("error writing response")
if _, err := h.store.CreateUser(r.Context(), store.CreateUserParams{
TwitterID: twitterUser.ID,
Username: twitterUser.Username,
Name: twitterUser.Name,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}); err != nil {
h.logger.With("err", err).Error("error upserting user")
http.Error(w, "error saving user", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}

View File

@ -11,7 +11,9 @@ import (
"git.netflux.io/rob/elon-eats-my-tweets/config"
"git.netflux.io/rob/elon-eats-my-tweets/generated/mocks"
"git.netflux.io/rob/elon-eats-my-tweets/generated/store"
"git.netflux.io/rob/elon-eats-my-tweets/httpserver"
"git.netflux.io/rob/elon-eats-my-tweets/twitter"
"github.com/gorilla/sessions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@ -44,6 +46,7 @@ func TestGetIndex(t *testing.T) {
config.Config{},
templates,
&mocks.Store{},
func(*http.Client) httpserver.TwitterAPIClient { return &mocks.TwitterAPIClient{} },
&mocks.SessionStore{},
&mockTokenGenerator{},
zap.NewNop(),
@ -99,6 +102,7 @@ func TestLogin(t *testing.T) {
},
templates,
&mocks.Store{},
func(*http.Client) httpserver.TwitterAPIClient { return &mocks.TwitterAPIClient{} },
&mockSessionStore,
&mockTokenGenerator{tokens: []string{"state", "pkceVerifier"}},
zap.NewNop(),
@ -133,6 +137,8 @@ func TestCallback(t *testing.T) {
code string
sessionReadError error
oauth2StatusCode int
getTwitterUserError error
createUserError error
wantStatusCode int
wantError string
}{
@ -183,6 +189,28 @@ func TestCallback(t *testing.T) {
wantStatusCode: http.StatusForbidden,
wantError: "error exchanging code",
},
{
name: "error fetching user from twitter",
state: "mystate",
sessionState: "mystate",
code: "mycode",
sessionPkceVerifier: "mypkceverifier",
oauth2StatusCode: http.StatusOK,
getTwitterUserError: errors.New("nothing to see here"),
wantStatusCode: http.StatusInternalServerError,
wantError: "error validating user",
},
{
name: "error storing user",
state: "mystate",
sessionState: "mystate",
code: "mycode",
sessionPkceVerifier: "mypkceverifier",
oauth2StatusCode: http.StatusOK,
createUserError: errors.New("oh no"),
wantStatusCode: http.StatusInternalServerError,
wantError: "error saving user",
},
{
name: "successful exchange",
state: "mystate",
@ -196,14 +224,20 @@ func TestCallback(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var mockStore mocks.Store
var mockSessionStore mocks.SessionStore
sess := sessions.NewSession(&mockSessionStore, "elon_session")
sess.Values["state"] = tc.sessionState
sess.Values["pkce_verifier"] = tc.sessionPkceVerifier
mockSessionStore.On("Get", mock.Anything, "elon_session").Return(sess, tc.sessionReadError)
var mockTwitterClient mocks.TwitterAPIClient
mockTwitterClient.On("GetMe").Return(&twitter.User{ID: "1", Name: "foo", Username: "Foo Bar"}, tc.getTwitterUserError)
var mockStore mocks.Store
mockStore.On("CreateUser", mock.Anything, mock.MatchedBy(func(params store.CreateUserParams) bool {
return params.TwitterID == "1" && params.Name == "foo" && params.Username == "Foo Bar"
})).Return(store.User{}, tc.createUserError)
const callbackURL = "https://www.example.com/callback"
oauthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
@ -234,6 +268,7 @@ func TestCallback(t *testing.T) {
},
templates,
&mockStore,
func(*http.Client) httpserver.TwitterAPIClient { return &mockTwitterClient },
&mockSessionStore,
nil,
zap.NewNop(),

View File

@ -17,6 +17,7 @@ func loggerMiddleware(l *zap.Logger) func(next http.Handler) http.Handler {
defer func() {
l.Info("HTTP",
zap.String("proto", r.Proto),
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.Duration("dur", time.Since(t1)),
zap.Int("status", ww.Status()),

View File

@ -12,6 +12,7 @@ import (
"git.netflux.io/rob/elon-eats-my-tweets/config"
"git.netflux.io/rob/elon-eats-my-tweets/generated/store"
"git.netflux.io/rob/elon-eats-my-tweets/httpserver"
"git.netflux.io/rob/elon-eats-my-tweets/twitter"
"github.com/gorilla/sessions"
"github.com/jackc/pgx/v4/pgxpool"
"go.uber.org/zap"
@ -45,6 +46,7 @@ func main() {
cfg,
templates,
store,
func(c *http.Client) httpserver.TwitterAPIClient { return twitter.NewAPIClient(c) },
sessions.NewCookieStore([]byte(cfg.SessionKey)),
httpserver.RandomTokenGenerator{},
logger,

View File

@ -2,13 +2,15 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
twitter_id int NOT NULL,
twitter_id CHARACTER VARYING(256) NOT NULL,
username CHARACTER VARYING(255) NOT NULL,
name CHARACTER VARYING(255) NOT NULL,
access_token CHARACTER VARYING(512) NOT NULL,
refresh_token CHARACTER VARYING(512) NOT NULL,
delete_tweets_enabled boolean NOT NULL DEFAULT false,
delete_tweets_num_per_iteration int NOT NULL DEFAULT 0,
delete_tweets_num_per_iteration int NOT NULL DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
)
);
CREATE UNIQUE INDEX index_users_on_twitter_id ON users (twitter_id);

View File

@ -1,4 +1,10 @@
-- name: CreateUser :one
INSERT INTO users (twitter_id, username, name, access_token, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO users (twitter_id, username, name, access_token, refresh_token, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (twitter_id)
DO
UPDATE SET access_token = EXCLUDED.access_token, refresh_token = EXCLUDED.refresh_token, username = EXCLUDED.username, name = EXCLUDED.name, updated_at = EXCLUDED.updated_at
RETURNING *;
-- name: GetUserByTwitterID :one
SELECT * FROM users WHERE twitter_id = $1;

53
twitter/api.go Normal file
View File

@ -0,0 +1,53 @@
package twitter
//go:generate mockery --recursive --name APIClient --structname TwitterAPIClient --filename TwitterAPIClient.go --output ../generated/mocks
import (
"encoding/json"
"fmt"
"net/http"
)
type User struct {
ID string
Name string
Username string
}
func NewAPIClient(httpclient *http.Client) *APIClient {
return &APIClient{httpclient}
}
type APIClient struct {
*http.Client
}
func (c *APIClient) GetMe() (*User, error) {
type oauthResponse struct {
Data struct {
ID string `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
} `json:"data"`
}
resp, err := c.Get("https://api.twitter.com/2/users/me")
if err != nil {
return nil, fmt.Errorf("error fetching resource: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("error fetching resource: status code %d", resp.StatusCode)
}
var oauthResp oauthResponse
if err = json.NewDecoder(resp.Body).Decode(&oauthResp); err != nil {
return nil, fmt.Errorf("error decoding resource: %v", err)
}
return &User{
ID: oauthResp.Data.ID,
Name: oauthResp.Data.Name,
Username: oauthResp.Data.Username,
}, nil
}