├── .gitignore ├── .gitmodules ├── .travis.yml ├── Makefile ├── go.mod ├── go.sum ├── gobuild.sh ├── gomod.sh ├── logo.png ├── model ├── Article.go ├── ArticlePriorityQueue.go ├── Article_test.go ├── Auth.go ├── Comment.go ├── InputError.go └── User.go ├── readme.md ├── route ├── articles-feed-get │ └── main.go ├── articles-get │ └── main.go ├── articles-post │ └── main.go ├── articles-slug-delete │ └── main.go ├── articles-slug-get │ └── main.go ├── articles-slug-put │ └── main.go ├── comments-delete │ └── main.go ├── comments-get │ └── main.go ├── comments-post │ └── main.go ├── favorite-delete │ └── main.go ├── favorite-post │ └── main.go ├── profiles-follow-delete │ └── main.go ├── profiles-follow-post │ └── main.go ├── profiles-get │ └── main.go ├── tags-get │ └── main.go ├── user-get │ └── main.go ├── user-put │ └── main.go ├── users-login-post │ └── main.go └── users-post │ └── main.go ├── serverless.yml ├── service ├── ArticleService.go ├── ArticleTagService.go ├── CommentService.go ├── CommonDBOperation.go ├── DynamoDBClient.go ├── FavoriteArticleService.go ├── FollowService.go ├── Rand.go ├── TableName.go ├── TagService.go ├── UserService.go ├── Util.go └── Util_test.go └── util ├── ErrorResponse.go ├── Math.go ├── StringSet.go └── SuccessResponse.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Serverless directories 2 | .serverless 3 | 4 | # golang output binary directory 5 | bin 6 | 7 | # golang vendor (dependencies) directory 8 | vendor 9 | 10 | # Binaries for programs and plugins 11 | *.exe 12 | *.exe~ 13 | *.dll 14 | *.so 15 | *.dylib 16 | 17 | # Test binary, build with `go test -c` 18 | *.test 19 | 20 | # Output of the go coverage tool, specifically when used with LiteIDE 21 | *.out 22 | 23 | # Custom 24 | /.idea/ 25 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "angularjs-realworld-example-app"] 2 | path = angularjs-realworld-example-app 3 | url = https://github.com/chrisxue815/angularjs-realworld-example-app.git 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build clean deploy gomodgen 2 | 3 | build: gomodgen 4 | ./gobuild.sh 5 | 6 | clean: 7 | rm -rf ./bin ./vendor Gopkg.lock 8 | 9 | deploy: clean build 10 | sls deploy --verbose 11 | 12 | gomodgen: 13 | chmod u+x gomod.sh 14 | ./gomod.sh 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chrisxue815/realworld-aws-lambda-dynamodb-go 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/aws/aws-lambda-go v1.6.0 7 | github.com/aws/aws-sdk-go v1.23.15 8 | github.com/dgrijalva/jwt-go v3.2.0+incompatible 9 | github.com/gosimple/slug v1.7.0 10 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect 11 | github.com/stretchr/testify v1.5.1 12 | golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472 13 | ) 14 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aws/aws-lambda-go v1.6.0 h1:T+u/g79zPKw1oJM7xYhvpq7i4Sjc0iVsXZUaqRVVSOg= 2 | github.com/aws/aws-lambda-go v1.6.0/go.mod h1:zUsUQhAUjYzR8AuduJPCfhBuKWUaDbQiPOG+ouzmE1A= 3 | github.com/aws/aws-sdk-go v1.23.15 h1:ut2ZzO0A34Ds18NXvvkWWKyO4aZqQ9uZquslWzCQvGU= 4 | github.com/aws/aws-sdk-go v1.23.15/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 8 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 9 | github.com/gosimple/slug v1.7.0 h1:BlCZq+BMGn+riOZuRKnm60Fe7+jX9ck6TzzkN1r8TW8= 10 | github.com/gosimple/slug v1.7.0/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0= 11 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= 12 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= 16 | github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= 17 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 18 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 19 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 20 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 21 | golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472 h1:Gv7RPwsi3eZ2Fgewe3CBsuOebPwO27PoXzRpJPsvSSM= 22 | golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 23 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 24 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 25 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 26 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 27 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 28 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 29 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 30 | -------------------------------------------------------------------------------- /gobuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | for r in route/*; do 3 | if [ -d "$r" ]; then 4 | r=$(basename "$r") 5 | env GO111MODULE=on GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/$r route/$r/main.go 6 | fi 7 | done 8 | -------------------------------------------------------------------------------- /gomod.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eu 3 | 4 | touch go.mod 5 | 6 | PROJECT_NAME=$(basename $(pwd | xargs dirname)) 7 | CURRENT_DIR=$(basename $(pwd)) 8 | 9 | CONTENT=$(cat <<-EOD 10 | module github.com/${PROJECT_NAME}/${CURRENT_DIR} 11 | 12 | require github.com/aws/aws-lambda-go v1.6.0 13 | EOD 14 | ) 15 | 16 | echo "$CONTENT" > go.mod 17 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisxue815/realworld-aws-lambda-dynamodb-go/8dec498a3feda8928f1bae408258b61360e7eb33/logo.png -------------------------------------------------------------------------------- /model/Article.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gosimple/slug" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | const TimestampFormat = "2006-01-02T15:04:05.000Z" 11 | const MaxArticleId = 0x1000000 // exclusive 12 | const MaxNumTagsPerArticle = 5 13 | 14 | type Article struct { 15 | ArticleId int64 16 | Slug string 17 | Title string 18 | Description string 19 | Body string 20 | TagList []string 21 | CreatedAt int64 22 | UpdatedAt int64 23 | FavoritesCount int64 24 | Author string 25 | Dummy byte // Always 0, used for sorting articles by index CreatedAt 26 | } 27 | 28 | type ArticleTag struct { 29 | Tag string 30 | ArticleId int64 31 | CreatedAt int64 32 | } 33 | 34 | type Tag struct { 35 | Tag string 36 | ArticleCount int64 37 | Dummy byte // Always 0, used for sorting articles by index ArticleCount 38 | } 39 | 40 | type FavoriteArticleKey struct { 41 | Username string 42 | ArticleId int64 43 | } 44 | 45 | type FavoriteArticle struct { 46 | FavoriteArticleKey 47 | FavoritedAt int64 48 | } 49 | 50 | func (article *Article) Validate() error { 51 | if article.Title == "" { 52 | return NewInputError("title", "can't be blank") 53 | } 54 | 55 | if article.Description == "" { 56 | return NewInputError("description", "can't be blank") 57 | } 58 | 59 | if article.Body == "" { 60 | return NewInputError("body", "can't be blank") 61 | } 62 | 63 | if article.TagList == nil { 64 | article.TagList = make([]string, 0) 65 | } else if len(article.TagList) > MaxNumTagsPerArticle { 66 | return NewInputError("tagList", fmt.Sprintf("cannot add more than %d tags per article", MaxNumTagsPerArticle)) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (article *Article) MakeSlug() { 73 | slugPrefix := slug.Make(article.Title) 74 | article.Slug = slugPrefix + "-" + strconv.FormatInt(article.ArticleId, 16) 75 | } 76 | 77 | func SlugToArticleId(slug string) (int64, error) { 78 | dashIndex := strings.LastIndexByte(slug, '-') 79 | 80 | articleId, err := strconv.ParseInt(slug[dashIndex+1:], 16, 64) 81 | if err != nil { 82 | return 0, NewInputError("slug", "invalid") 83 | } 84 | 85 | return articleId, nil 86 | } 87 | -------------------------------------------------------------------------------- /model/ArticlePriorityQueue.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "container/heap" 4 | 5 | type ArticlePriorityQueue [][]Article 6 | 7 | func (pq ArticlePriorityQueue) Len() int { return len(pq) } 8 | 9 | func (pq ArticlePriorityQueue) Less(i, j int) bool { 10 | // Pop empty lists first, to reduce computation complexity 11 | if len(pq[i]) == 0 { 12 | return true 13 | } 14 | if len(pq[j]) == 0 { 15 | return false 16 | } 17 | // We want Pop to give us the latest, not earliest, article so we use greater than here. 18 | return pq[i][0].CreatedAt > pq[j][0].CreatedAt 19 | } 20 | 21 | func (pq ArticlePriorityQueue) Swap(i, j int) { 22 | pq[i], pq[j] = pq[j], pq[i] 23 | } 24 | 25 | func (pq *ArticlePriorityQueue) Push(x interface{}) { 26 | item := x.([]Article) 27 | *pq = append(*pq, item) 28 | } 29 | 30 | func (pq *ArticlePriorityQueue) Pop() interface{} { 31 | old := *pq 32 | n := len(old) 33 | item := old[n-1] 34 | old[n-1] = nil // avoid memory leak 35 | *pq = old[0 : n-1] 36 | return item 37 | } 38 | 39 | func MergeArticles(pq ArticlePriorityQueue, offset, limit int) []Article { 40 | merged := make([]Article, 0, limit) 41 | heap.Init(&pq) 42 | numVisitedArticles := 0 43 | 44 | for len(pq) > 0 && numVisitedArticles < offset+limit { 45 | list := pq[0] 46 | 47 | if len(list) == 0 { 48 | heap.Pop(&pq) 49 | } else { 50 | if numVisitedArticles >= offset { 51 | article := list[0] 52 | merged = append(merged, article) 53 | } 54 | pq[0] = list[1:] 55 | heap.Fix(&pq, 0) 56 | numVisitedArticles++ 57 | } 58 | } 59 | 60 | return merged 61 | } 62 | -------------------------------------------------------------------------------- /model/Article_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestSlugToArticleId(t *testing.T) { 9 | testCases := []struct { 10 | slug string 11 | expected int64 12 | expectedError bool 13 | }{ 14 | {"how-to-train-your-dragon-74728a", 0x74728a, false}, 15 | {"74728a", 0x74728a, false}, 16 | } 17 | 18 | for _, testCase := range testCases { 19 | actual, err := SlugToArticleId("how-to-train-your-dragon-74728a") 20 | assert.Equal(t, testCase.expected, actual, "%+v", testCase) 21 | assert.Equal(t, testCase.expectedError, err != nil, "%+v", testCase) 22 | } 23 | } 24 | 25 | func PassArticleByValue(article Article, goPanic bool) { 26 | if goPanic { 27 | if article.ArticleId == 0 { 28 | // noinline 29 | // https://github.com/golang/go/wiki/CompilerOptimizations#function-inlining 30 | panic(nil) 31 | } 32 | } 33 | } 34 | 35 | func PassArticleByPointer(article *Article, goPanic bool) { 36 | if goPanic { 37 | if article.ArticleId == 0 { 38 | // noinline 39 | // https://github.com/golang/go/wiki/CompilerOptimizations#function-inlining 40 | panic(nil) 41 | } 42 | } 43 | } 44 | 45 | func BenchmarkPassArticleByValue(b *testing.B) { 46 | article := Article{} 47 | for i := 0; i < b.N; i++ { 48 | PassArticleByValue(article, false) 49 | } 50 | } 51 | 52 | func BenchmarkPassArticleByPointer(b *testing.B) { 53 | article := Article{} 54 | for i := 0; i < b.N; i++ { 55 | PassArticleByPointer(&article, false) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /model/Auth.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/dgrijalva/jwt-go" 6 | "golang.org/x/crypto/scrypt" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | const TokenExpirationDays = 60 12 | 13 | var passwordSalt = []byte("KU2YVXA7BSNExJIvemcdz61eL86IJDCC") 14 | var jwtSecret = []byte("C92cw5od80NCWIvu4NZ8AKp5NyTbnBmG") // TODO: Generate random secrets and store in DynamoDB 15 | 16 | func Scrypt(password string) ([]byte, error) { 17 | // https://godoc.org/golang.org/x/crypto/scrypt 18 | passwordHash, err := scrypt.Key([]byte(password), passwordSalt, 32768, 8, 1, PasswordKeyLength) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return passwordHash, nil 24 | } 25 | 26 | func GenerateToken(username string) (string, error) { 27 | now := time.Now().UTC() 28 | exp := now.AddDate(0, 0, TokenExpirationDays).Unix() 29 | 30 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 31 | "sub": username, 32 | "exp": exp, 33 | }) 34 | 35 | return token.SignedString(jwtSecret) 36 | } 37 | 38 | func VerifyAuthorization(auth string) (string, string, error) { 39 | parts := strings.SplitN(auth, " ", 2) 40 | if len(parts) != 2 || parts[0] != "Token" { 41 | return "", "", NewInputError("Authorization", "invalid format") 42 | } 43 | 44 | token := parts[1] 45 | username, err := VerifyToken(token) 46 | return username, token, err 47 | } 48 | 49 | func VerifyToken(tokenString string) (string, error) { 50 | token, err := jwt.Parse(tokenString, validateToken) 51 | 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | if token == nil || !token.Valid { 57 | return "", NewInputError("Authorization", "invalid token") 58 | } 59 | 60 | claims, ok := token.Claims.(jwt.MapClaims) 61 | if !ok { 62 | return "", NewInputError("Authorization", "invalid claims") 63 | } 64 | 65 | if !claims.VerifyExpiresAt(time.Now().UTC().Unix(), true) { 66 | return "", NewInputError("Authorization", "token expired") 67 | } 68 | 69 | username, ok := claims["sub"].(string) 70 | if !ok { 71 | return "", NewInputError("Authorization", "sub missing") 72 | } 73 | 74 | return username, nil 75 | } 76 | 77 | func validateToken(token *jwt.Token) (interface{}, error) { 78 | _, ok := token.Method.(*jwt.SigningMethodHMAC) 79 | if !ok { 80 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 81 | } 82 | 83 | return jwtSecret, nil 84 | } 85 | -------------------------------------------------------------------------------- /model/Comment.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | const MaxCommentId = 0x1000000 // exclusive 4 | 5 | type CommentKey struct { 6 | ArticleId int64 7 | CommentId int64 8 | } 9 | 10 | type Comment struct { 11 | CommentKey 12 | CreatedAt int64 13 | UpdatedAt int64 14 | Body string 15 | Author string 16 | } 17 | 18 | func (comment *Comment) Validate() error { 19 | if comment.Body == "" { 20 | return NewInputError("body", "can't be blank") 21 | } 22 | 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /model/InputError.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "encoding/json" 4 | 5 | type InputError map[string][]string 6 | 7 | func (e InputError) Error() string { 8 | js, err := json.Marshal(e) 9 | if err != nil { 10 | return err.Error() 11 | } 12 | 13 | return string(js) 14 | } 15 | 16 | func NewInputError(inputName, message string) InputError { 17 | return InputError{ 18 | inputName: {message}, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /model/User.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const MinPasswordLength = 0 8 | const PasswordKeyLength = 64 9 | 10 | type User struct { 11 | Username string 12 | Email string 13 | PasswordHash []byte 14 | Image string 15 | Bio string 16 | } 17 | 18 | type EmailUser struct { 19 | Email string 20 | Username string 21 | } 22 | 23 | type Follow struct { 24 | Follower string 25 | Publisher string 26 | } 27 | 28 | func (u *User) Validate() error { 29 | if u.Username == "" { 30 | return NewInputError("username", "can't be blank") 31 | } 32 | 33 | if u.Email == "" { 34 | return NewInputError("email", "can't be blank") 35 | } 36 | 37 | if u.PasswordHash == nil || len(u.PasswordHash) != PasswordKeyLength { 38 | return NewInputError("password", "can't be blank") 39 | } 40 | 41 | return nil 42 | } 43 | 44 | func ValidatePassword(password string) error { 45 | if len(password) < MinPasswordLength { 46 | return NewInputError("password", fmt.Sprintf("must be at least %d characters in length", MinPasswordLength)) 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | > ### AWS Lambda + DynamoDB + Go codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 4 | 5 | ### [Demo](https://chrisxue815.github.io/realworld/build/#/) 6 | 7 | [![Build Status](https://travis-ci.org/chrisxue815/realworld-aws-lambda-dynamodb-go.svg?branch=master)](https://travis-ci.org/chrisxue815/realworld-aws-lambda-dynamodb-go) 8 | 9 | This codebase was created to demonstrate a fully fledged fullstack application built with **AWS Lambda + DynamoDB + Go** including CRUD operations, authentication, routing, pagination, and more. 10 | 11 | We've gone to great lengths to adhere to the **AWS Lambda + DynamoDB + Go** community styleguides & best practices. 12 | 13 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 14 | 15 | # Getting started 16 | 17 | ## Prerequisite 18 | 19 | * Install Go, Node.js, Serverless CLI, AWS CLI 20 | * In `angularjs-realworld-example-app`, run `npm install` 21 | 22 | ## Build and deploy backend 23 | 24 | In the root directory of this project: 25 | 26 | * `make build` 27 | * `sls deploy --stage dev` 28 | 29 | ## Build and serve frontend 30 | 31 | In `angularjs-realworld-example-app`: 32 | 33 | * `npx gulp` 34 | 35 | # How it works 36 | 37 | Routes and their handlers are defined in `serverless.yml`. 38 | 39 | For example, the following section means `POST /users` is handled by `bin/users-post`, which is built from `route/users-post/main.go`. 40 | 41 | ``` 42 | users-post: 43 | handler: bin/users-post 44 | events: 45 | - http: 46 | path: users 47 | method: post 48 | cors: true 49 | ``` 50 | 51 | # Design choices 52 | * Scrypt-based password hashing 53 | * Input validation 54 | * Data consistency with DynamoDB transactions 55 | 56 | These tradeoffs were made for simpler code: 57 | * Hardcoded Scrypt secret. Downside: tokens can't be invalidated 58 | * Shared states (like DB and RNG) are singletons, no dependency injections used. Downside: lifecycles of shared states are not controllable. Potential memory leak. Unit-test-unfriendly 59 | * Usernames are not changeable 60 | * Usernames are case-sensitive 61 | * Performance bottleneck in global secondary indices with a single hash-key value, like ArticleTable.CreatedAt and TagTable.ArticleCount 62 | * Performance bottleneck in fan-in-based article feed aggregation 63 | -------------------------------------------------------------------------------- /route/articles-feed-get/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | type Response struct { 14 | Articles []ArticleResponse `json:"articles"` 15 | ArticlesCount int `json:"articlesCount"` 16 | } 17 | 18 | type ArticleResponse struct { 19 | Slug string `json:"slug"` 20 | Title string `json:"title"` 21 | Description string `json:"description"` 22 | Body string `json:"body"` 23 | TagList []string `json:"tagList"` 24 | CreatedAt string `json:"createdAt"` 25 | UpdatedAt string `json:"updatedAt"` 26 | Favorited bool `json:"favorited"` 27 | FavoritesCount int64 `json:"favoritesCount"` 28 | Author AuthorResponse `json:"author"` 29 | } 30 | 31 | type AuthorResponse struct { 32 | Username string `json:"username"` 33 | Bio string `json:"bio"` 34 | Image string `json:"image"` 35 | Following bool `json:"following"` 36 | } 37 | 38 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 39 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 40 | if err != nil { 41 | return util.NewUnauthorizedResponse() 42 | } 43 | 44 | offset, err := strconv.Atoi(input.QueryStringParameters["offset"]) 45 | if err != nil { 46 | offset = 0 47 | } 48 | 49 | limit, err := strconv.Atoi(input.QueryStringParameters["limit"]) 50 | if err != nil { 51 | limit = 20 52 | } 53 | 54 | articles, err := service.GetFeed(user.Username, offset, limit) 55 | if err != nil { 56 | return util.NewErrorResponse(err) 57 | } 58 | 59 | isFavorited, authors, _, err := service.GetArticleRelatedProperties(user, articles, false) 60 | if err != nil { 61 | return util.NewErrorResponse(err) 62 | } 63 | 64 | articleResponses := make([]ArticleResponse, 0, len(articles)) 65 | 66 | for i, article := range articles { 67 | articleResponses = append(articleResponses, ArticleResponse{ 68 | Slug: article.Slug, 69 | Title: article.Title, 70 | Description: article.Description, 71 | Body: article.Body, 72 | TagList: article.TagList, 73 | CreatedAt: time.Unix(0, article.CreatedAt).Format(model.TimestampFormat), 74 | UpdatedAt: time.Unix(0, article.UpdatedAt).Format(model.TimestampFormat), 75 | Favorited: isFavorited[i], 76 | FavoritesCount: article.FavoritesCount, 77 | Author: AuthorResponse{ 78 | Username: authors[i].Username, 79 | Bio: authors[i].Bio, 80 | Image: authors[i].Image, 81 | Following: true, 82 | }, 83 | }) 84 | } 85 | 86 | response := Response{ 87 | Articles: articleResponses, 88 | ArticlesCount: len(articleResponses), 89 | } 90 | 91 | return util.NewSuccessResponse(200, response) 92 | } 93 | 94 | func main() { 95 | lambda.Start(Handle) 96 | } 97 | -------------------------------------------------------------------------------- /route/articles-get/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 9 | "strconv" 10 | "time" 11 | ) 12 | 13 | type Response struct { 14 | Articles []ArticleResponse `json:"articles"` 15 | ArticlesCount int `json:"articlesCount"` 16 | } 17 | 18 | type ArticleResponse struct { 19 | Slug string `json:"slug"` 20 | Title string `json:"title"` 21 | Description string `json:"description"` 22 | Body string `json:"body"` 23 | TagList []string `json:"tagList"` 24 | CreatedAt string `json:"createdAt"` 25 | UpdatedAt string `json:"updatedAt"` 26 | Favorited bool `json:"favorited"` 27 | FavoritesCount int64 `json:"favoritesCount"` 28 | Author AuthorResponse `json:"author"` 29 | } 30 | 31 | type AuthorResponse struct { 32 | Username string `json:"username"` 33 | Bio string `json:"bio"` 34 | Image string `json:"image"` 35 | Following bool `json:"following"` 36 | } 37 | 38 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 39 | user, _, _ := service.GetCurrentUser(input.Headers["Authorization"]) 40 | 41 | offset, err := strconv.Atoi(input.QueryStringParameters["offset"]) 42 | if err != nil { 43 | offset = 0 44 | } 45 | 46 | limit, err := strconv.Atoi(input.QueryStringParameters["limit"]) 47 | if err != nil { 48 | limit = 20 49 | } 50 | 51 | author := input.QueryStringParameters["author"] 52 | tag := input.QueryStringParameters["tag"] 53 | favorited := input.QueryStringParameters["favorited"] 54 | 55 | articles, err := service.GetArticles(offset, limit, author, tag, favorited) 56 | if err != nil { 57 | return util.NewErrorResponse(err) 58 | } 59 | 60 | isFavorited, authors, following, err := service.GetArticleRelatedProperties(user, articles, true) 61 | if err != nil { 62 | return util.NewErrorResponse(err) 63 | } 64 | 65 | articleResponses := make([]ArticleResponse, 0, len(articles)) 66 | 67 | for i, article := range articles { 68 | articleResponses = append(articleResponses, ArticleResponse{ 69 | Slug: article.Slug, 70 | Title: article.Title, 71 | Description: article.Description, 72 | Body: article.Body, 73 | TagList: article.TagList, 74 | CreatedAt: time.Unix(0, article.CreatedAt).Format(model.TimestampFormat), 75 | UpdatedAt: time.Unix(0, article.UpdatedAt).Format(model.TimestampFormat), 76 | Favorited: isFavorited[i], 77 | FavoritesCount: article.FavoritesCount, 78 | Author: AuthorResponse{ 79 | Username: authors[i].Username, 80 | Bio: authors[i].Bio, 81 | Image: authors[i].Image, 82 | Following: following[i], 83 | }, 84 | }) 85 | } 86 | 87 | response := Response{ 88 | Articles: articleResponses, 89 | ArticlesCount: len(articleResponses), 90 | } 91 | 92 | return util.NewSuccessResponse(200, response) 93 | } 94 | 95 | func main() { 96 | lambda.Start(Handle) 97 | } 98 | -------------------------------------------------------------------------------- /route/articles-post/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/aws/aws-lambda-go/events" 6 | "github.com/aws/aws-lambda-go/lambda" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 9 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 10 | "time" 11 | ) 12 | 13 | type Request struct { 14 | Article ArticleRequest `json:"article"` 15 | } 16 | 17 | type ArticleRequest struct { 18 | Title string `json:"title"` 19 | Description string `json:"description"` 20 | Body string `json:"body"` 21 | TagList []string `json:"tagList"` 22 | } 23 | 24 | type Response struct { 25 | Article ArticleResponse `json:"article"` 26 | } 27 | 28 | type ArticleResponse struct { 29 | Slug string `json:"slug"` 30 | Title string `json:"title"` 31 | Description string `json:"description"` 32 | Body string `json:"body"` 33 | TagList []string `json:"tagList"` 34 | CreatedAt string `json:"createdAt"` 35 | UpdatedAt string `json:"updatedAt"` 36 | Favorited bool `json:"favorited"` 37 | FavoritesCount int64 `json:"favoritesCount"` 38 | Author AuthorResponse `json:"author"` 39 | } 40 | 41 | type AuthorResponse struct { 42 | Username string `json:"username"` 43 | Bio string `json:"bio"` 44 | Image string `json:"image"` 45 | Following bool `json:"following"` 46 | } 47 | 48 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 49 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 50 | if err != nil { 51 | return util.NewUnauthorizedResponse() 52 | } 53 | 54 | request := Request{} 55 | err = json.Unmarshal([]byte(input.Body), &request) 56 | if err != nil { 57 | return util.NewErrorResponse(err) 58 | } 59 | 60 | now := time.Now().UTC() 61 | nowUnixNano := now.UnixNano() 62 | nowStr := now.Format(model.TimestampFormat) 63 | 64 | article := model.Article{ 65 | Title: request.Article.Title, 66 | Description: request.Article.Description, 67 | Body: request.Article.Body, 68 | TagList: request.Article.TagList, // TODO .distinct() 69 | CreatedAt: nowUnixNano, 70 | UpdatedAt: nowUnixNano, 71 | Author: user.Username, 72 | } 73 | 74 | err = service.PutArticle(&article) 75 | if err != nil { 76 | return util.NewErrorResponse(err) 77 | } 78 | 79 | response := Response{ 80 | Article: ArticleResponse{ 81 | Slug: article.Slug, 82 | Title: article.Title, 83 | Description: article.Description, 84 | Body: article.Body, 85 | TagList: article.TagList, 86 | CreatedAt: nowStr, 87 | UpdatedAt: nowStr, 88 | Favorited: false, 89 | FavoritesCount: 0, 90 | Author: AuthorResponse{ 91 | Username: user.Username, 92 | Bio: user.Bio, 93 | Image: user.Image, 94 | Following: false, 95 | }, 96 | }, 97 | } 98 | 99 | return util.NewSuccessResponse(201, response) 100 | } 101 | 102 | func main() { 103 | lambda.Start(Handle) 104 | } 105 | -------------------------------------------------------------------------------- /route/articles-slug-delete/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 8 | ) 9 | 10 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 11 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 12 | if err != nil { 13 | return util.NewUnauthorizedResponse() 14 | } 15 | 16 | err = service.DeleteArticle(input.PathParameters["slug"], user.Username) 17 | if err != nil { 18 | return util.NewErrorResponse(err) 19 | } 20 | 21 | return util.NewSuccessResponse(200, nil) 22 | } 23 | 24 | func main() { 25 | lambda.Start(Handle) 26 | } 27 | -------------------------------------------------------------------------------- /route/articles-slug-get/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 9 | "time" 10 | ) 11 | 12 | type Response struct { 13 | Article ArticleResponse `json:"article"` 14 | } 15 | 16 | type ArticleResponse struct { 17 | Slug string `json:"slug"` 18 | Title string `json:"title"` 19 | Description string `json:"description"` 20 | Body string `json:"body"` 21 | TagList []string `json:"tagList"` 22 | CreatedAt string `json:"createdAt"` 23 | UpdatedAt string `json:"updatedAt"` 24 | Favorited bool `json:"favorited"` 25 | FavoritesCount int64 `json:"favoritesCount"` 26 | Author AuthorResponse `json:"author"` 27 | } 28 | 29 | type AuthorResponse struct { 30 | Username string `json:"username"` 31 | Bio string `json:"bio"` 32 | Image string `json:"image"` 33 | Following bool `json:"following"` 34 | } 35 | 36 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 37 | user, _, _ := service.GetCurrentUser(input.Headers["Authorization"]) 38 | 39 | article, err := service.GetArticleBySlug(input.PathParameters["slug"]) 40 | if err != nil { 41 | return util.NewErrorResponse(err) 42 | } 43 | 44 | isFavorited, authors, following, err := service.GetArticleRelatedProperties(user, []model.Article{article}, true) 45 | if err != nil { 46 | return util.NewErrorResponse(err) 47 | } 48 | 49 | response := Response{ 50 | Article: ArticleResponse{ 51 | Slug: article.Slug, 52 | Title: article.Title, 53 | Description: article.Description, 54 | Body: article.Body, 55 | TagList: article.TagList, 56 | CreatedAt: time.Unix(0, article.CreatedAt).Format(model.TimestampFormat), 57 | UpdatedAt: time.Unix(0, article.UpdatedAt).Format(model.TimestampFormat), 58 | Favorited: isFavorited[0], 59 | FavoritesCount: article.FavoritesCount, 60 | Author: AuthorResponse{ 61 | Username: authors[0].Username, 62 | Bio: authors[0].Bio, 63 | Image: authors[0].Image, 64 | Following: following[0], 65 | }, 66 | }, 67 | } 68 | 69 | return util.NewSuccessResponse(200, response) 70 | } 71 | 72 | func main() { 73 | lambda.Start(Handle) 74 | } 75 | -------------------------------------------------------------------------------- /route/articles-slug-put/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/aws/aws-lambda-go/events" 6 | "github.com/aws/aws-lambda-go/lambda" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 9 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 10 | "time" 11 | ) 12 | 13 | type Request struct { 14 | Article ArticleRequest `json:"article"` 15 | } 16 | 17 | type ArticleRequest struct { 18 | Title string `json:"title"` 19 | Description string `json:"description"` 20 | Body string `json:"body"` 21 | TagList []string `json:"tagList"` 22 | } 23 | 24 | type Response struct { 25 | Article ArticleResponse `json:"article"` 26 | } 27 | 28 | type ArticleResponse struct { 29 | Slug string `json:"slug"` 30 | Title string `json:"title"` 31 | Description string `json:"description"` 32 | Body string `json:"body"` 33 | TagList []string `json:"tagList"` 34 | CreatedAt string `json:"createdAt"` 35 | UpdatedAt string `json:"updatedAt"` 36 | Favorited bool `json:"favorited"` 37 | FavoritesCount int64 `json:"favoritesCount"` 38 | Author AuthorResponse `json:"author"` 39 | } 40 | 41 | type AuthorResponse struct { 42 | Username string `json:"username"` 43 | Bio string `json:"bio"` 44 | Image string `json:"image"` 45 | Following bool `json:"following"` 46 | } 47 | 48 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 49 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 50 | if err != nil { 51 | return util.NewUnauthorizedResponse() 52 | } 53 | 54 | request := Request{} 55 | err = json.Unmarshal([]byte(input.Body), &request) 56 | if err != nil { 57 | return util.NewErrorResponse(err) 58 | } 59 | 60 | oldArticle, err := service.GetArticleBySlug(input.PathParameters["slug"]) 61 | if err != nil { 62 | return util.NewErrorResponse(err) 63 | } 64 | 65 | newArticle := createNewArticle(request, oldArticle) 66 | 67 | err = service.UpdateArticle(oldArticle, &newArticle) 68 | if err != nil { 69 | return util.NewErrorResponse(err) 70 | } 71 | 72 | isFavorited, authors, following, err := service.GetArticleRelatedProperties(user, []model.Article{newArticle}, true) 73 | if err != nil { 74 | return util.NewErrorResponse(err) 75 | } 76 | 77 | response := Response{ 78 | Article: ArticleResponse{ 79 | Slug: newArticle.Slug, 80 | Title: newArticle.Title, 81 | Description: newArticle.Description, 82 | Body: newArticle.Body, 83 | TagList: newArticle.TagList, 84 | CreatedAt: time.Unix(0, newArticle.CreatedAt).Format(model.TimestampFormat), 85 | UpdatedAt: time.Unix(0, newArticle.UpdatedAt).Format(model.TimestampFormat), 86 | Favorited: isFavorited[0], 87 | FavoritesCount: newArticle.FavoritesCount, 88 | Author: AuthorResponse{ 89 | Username: authors[0].Username, 90 | Bio: authors[0].Bio, 91 | Image: authors[0].Image, 92 | Following: following[0], 93 | }, 94 | }, 95 | } 96 | 97 | return util.NewSuccessResponse(200, response) 98 | } 99 | 100 | func createNewArticle(request Request, oldArticle model.Article) model.Article { 101 | newArticle := model.Article{ 102 | ArticleId: oldArticle.ArticleId, 103 | Title: request.Article.Title, 104 | Description: request.Article.Description, 105 | Body: request.Article.Body, 106 | TagList: request.Article.TagList, 107 | CreatedAt: oldArticle.CreatedAt, 108 | UpdatedAt: time.Now().UTC().UnixNano(), 109 | FavoritesCount: oldArticle.FavoritesCount, 110 | Author: oldArticle.Author, 111 | } 112 | 113 | if newArticle.Title == "" { 114 | newArticle.Title = oldArticle.Title 115 | } 116 | 117 | if newArticle.Description == "" { 118 | newArticle.Description = oldArticle.Description 119 | } 120 | 121 | if newArticle.Body == "" { 122 | newArticle.Body = oldArticle.Body 123 | } 124 | 125 | if newArticle.TagList == nil { 126 | newArticle.TagList = oldArticle.TagList 127 | } 128 | 129 | return newArticle 130 | } 131 | 132 | func main() { 133 | lambda.Start(Handle) 134 | } 135 | -------------------------------------------------------------------------------- /route/comments-delete/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 9 | "strconv" 10 | ) 11 | 12 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 13 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 14 | if err != nil { 15 | return util.NewUnauthorizedResponse() 16 | } 17 | 18 | commentId, err := strconv.ParseInt(input.PathParameters["id"], 10, 64) 19 | if err != nil { 20 | return util.NewErrorResponse(model.NewInputError("id", "invalid")) 21 | } 22 | 23 | err = service.DeleteComment(input.PathParameters["slug"], commentId, user.Username) 24 | if err != nil { 25 | return util.NewErrorResponse(err) 26 | } 27 | 28 | return util.NewSuccessResponse(200, nil) 29 | } 30 | 31 | func main() { 32 | lambda.Start(Handle) 33 | } 34 | -------------------------------------------------------------------------------- /route/comments-get/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 9 | "time" 10 | ) 11 | 12 | type Response struct { 13 | Comments []CommentResponse `json:"comments"` 14 | } 15 | 16 | type CommentResponse struct { 17 | Id int64 `json:"id"` 18 | CreatedAt string `json:"createdAt"` 19 | UpdatedAt string `json:"updatedAt"` 20 | Body string `json:"body"` 21 | Author AuthorResponse `json:"author"` 22 | } 23 | 24 | type AuthorResponse struct { 25 | Username string `json:"username"` 26 | Bio string `json:"bio"` 27 | Image string `json:"image"` 28 | Following bool `json:"following"` 29 | } 30 | 31 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 32 | user, _, _ := service.GetCurrentUser(input.Headers["Authorization"]) 33 | 34 | comments, err := service.GetComments(input.PathParameters["slug"]) 35 | if err != nil { 36 | return util.NewErrorResponse(err) 37 | } 38 | 39 | authors, following, err := service.GetCommentRelatedProperties(user, comments) 40 | if err != nil { 41 | return util.NewErrorResponse(err) 42 | } 43 | 44 | commentResponses := make([]CommentResponse, 0, len(comments)) 45 | 46 | for i, comment := range comments { 47 | commentResponses = append(commentResponses, CommentResponse{ 48 | Id: comment.CommentId, 49 | Body: comment.Body, 50 | CreatedAt: time.Unix(0, comment.CreatedAt).Format(model.TimestampFormat), 51 | UpdatedAt: time.Unix(0, comment.UpdatedAt).Format(model.TimestampFormat), 52 | Author: AuthorResponse{ 53 | Username: authors[i].Username, 54 | Bio: authors[i].Bio, 55 | Image: authors[i].Image, 56 | Following: following[i], 57 | }, 58 | }) 59 | } 60 | 61 | response := Response{ 62 | Comments: commentResponses, 63 | } 64 | 65 | return util.NewSuccessResponse(200, response) 66 | } 67 | 68 | func main() { 69 | lambda.Start(Handle) 70 | } 71 | -------------------------------------------------------------------------------- /route/comments-post/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/aws/aws-lambda-go/events" 6 | "github.com/aws/aws-lambda-go/lambda" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 9 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 10 | "time" 11 | ) 12 | 13 | type Request struct { 14 | Comment CommentRequest `json:"comment"` 15 | } 16 | 17 | type CommentRequest struct { 18 | Body string `json:"body"` 19 | } 20 | 21 | type Response struct { 22 | Comment CommentResponse `json:"comment"` 23 | } 24 | 25 | type CommentResponse struct { 26 | Id int64 `json:"id"` 27 | CreatedAt string `json:"createdAt"` 28 | UpdatedAt string `json:"updatedAt"` 29 | Body string `json:"body"` 30 | Author AuthorResponse `json:"author"` 31 | } 32 | 33 | type AuthorResponse struct { 34 | Username string `json:"username"` 35 | Bio string `json:"bio"` 36 | Image string `json:"image"` 37 | Following bool `json:"following"` 38 | } 39 | 40 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 41 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 42 | if err != nil { 43 | return util.NewUnauthorizedResponse() 44 | } 45 | 46 | request := Request{} 47 | err = json.Unmarshal([]byte(input.Body), &request) 48 | if err != nil { 49 | return util.NewErrorResponse(err) 50 | } 51 | 52 | // Make sure article exists, at least at this point 53 | article, err := service.GetArticleBySlug(input.PathParameters["slug"]) 54 | if err != nil { 55 | return util.NewErrorResponse(err) 56 | } 57 | 58 | now := time.Now().UTC() 59 | nowUnixNano := now.UnixNano() 60 | nowStr := now.Format(model.TimestampFormat) 61 | 62 | comment := model.Comment{ 63 | CommentKey: model.CommentKey{ 64 | ArticleId: article.ArticleId, 65 | }, 66 | CreatedAt: nowUnixNano, 67 | UpdatedAt: nowUnixNano, 68 | Body: request.Comment.Body, 69 | Author: user.Username, 70 | } 71 | 72 | err = service.PutComment(&comment) 73 | if err != nil { 74 | return util.NewErrorResponse(err) 75 | } 76 | 77 | response := Response{ 78 | Comment: CommentResponse{ 79 | Id: comment.CommentId, 80 | Body: comment.Body, 81 | CreatedAt: nowStr, 82 | UpdatedAt: nowStr, 83 | Author: AuthorResponse{ 84 | Username: user.Username, 85 | Bio: user.Bio, 86 | Image: user.Image, 87 | Following: false, 88 | }, 89 | }, 90 | } 91 | 92 | return util.NewSuccessResponse(200, response) 93 | } 94 | 95 | func main() { 96 | lambda.Start(Handle) 97 | } 98 | -------------------------------------------------------------------------------- /route/favorite-delete/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 9 | "time" 10 | ) 11 | 12 | type Response struct { 13 | Article ArticleResponse `json:"article"` 14 | } 15 | 16 | type ArticleResponse struct { 17 | Slug string `json:"slug"` 18 | Title string `json:"title"` 19 | Description string `json:"description"` 20 | Body string `json:"body"` 21 | TagList []string `json:"tagList"` 22 | CreatedAt string `json:"createdAt"` 23 | UpdatedAt string `json:"updatedAt"` 24 | Favorited bool `json:"favorited"` 25 | FavoritesCount int64 `json:"favoritesCount"` 26 | Author AuthorResponse `json:"author"` 27 | } 28 | 29 | type AuthorResponse struct { 30 | Username string `json:"username"` 31 | Bio string `json:"bio"` 32 | Image string `json:"image"` 33 | Following bool `json:"following"` 34 | } 35 | 36 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 37 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 38 | if err != nil { 39 | return util.NewUnauthorizedResponse() 40 | } 41 | 42 | articleId, err := model.SlugToArticleId(input.PathParameters["slug"]) 43 | if err != nil { 44 | return util.NewErrorResponse(err) 45 | } 46 | 47 | favoriteArticleKey := model.FavoriteArticleKey{ 48 | Username: user.Username, 49 | ArticleId: articleId, 50 | } 51 | 52 | err = service.UnfavoriteArticle(favoriteArticleKey) 53 | if err != nil { 54 | return util.NewErrorResponse(err) 55 | } 56 | 57 | article, err := service.GetArticleByArticleId(articleId) 58 | if err != nil { 59 | return util.NewErrorResponse(err) 60 | } 61 | 62 | isFavorited, authors, following, err := service.GetArticleRelatedProperties(user, []model.Article{article}, true) 63 | if err != nil { 64 | return util.NewErrorResponse(err) 65 | } 66 | 67 | response := Response{ 68 | Article: ArticleResponse{ 69 | Slug: article.Slug, 70 | Title: article.Title, 71 | Description: article.Description, 72 | Body: article.Body, 73 | TagList: article.TagList, 74 | CreatedAt: time.Unix(0, article.CreatedAt).Format(model.TimestampFormat), 75 | UpdatedAt: time.Unix(0, article.UpdatedAt).Format(model.TimestampFormat), 76 | Favorited: isFavorited[0], 77 | FavoritesCount: article.FavoritesCount, 78 | Author: AuthorResponse{ 79 | Username: authors[0].Username, 80 | Bio: authors[0].Bio, 81 | Image: authors[0].Image, 82 | Following: following[0], 83 | }, 84 | }, 85 | } 86 | 87 | return util.NewSuccessResponse(200, response) 88 | } 89 | 90 | func main() { 91 | lambda.Start(Handle) 92 | } 93 | -------------------------------------------------------------------------------- /route/favorite-post/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 9 | "time" 10 | ) 11 | 12 | type Response struct { 13 | Article ArticleResponse `json:"article"` 14 | } 15 | 16 | type ArticleResponse struct { 17 | Slug string `json:"slug"` 18 | Title string `json:"title"` 19 | Description string `json:"description"` 20 | Body string `json:"body"` 21 | TagList []string `json:"tagList"` 22 | CreatedAt string `json:"createdAt"` 23 | UpdatedAt string `json:"updatedAt"` 24 | Favorited bool `json:"favorited"` 25 | FavoritesCount int64 `json:"favoritesCount"` 26 | Author AuthorResponse `json:"author"` 27 | } 28 | 29 | type AuthorResponse struct { 30 | Username string `json:"username"` 31 | Bio string `json:"bio"` 32 | Image string `json:"image"` 33 | Following bool `json:"following"` 34 | } 35 | 36 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 37 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 38 | if err != nil { 39 | return util.NewUnauthorizedResponse() 40 | } 41 | 42 | articleId, err := model.SlugToArticleId(input.PathParameters["slug"]) 43 | if err != nil { 44 | return util.NewErrorResponse(err) 45 | } 46 | 47 | favoriteArticle := model.FavoriteArticle{ 48 | FavoriteArticleKey: model.FavoriteArticleKey{ 49 | Username: user.Username, 50 | ArticleId: articleId, 51 | }, 52 | FavoritedAt: time.Now().UTC().UnixNano(), 53 | } 54 | 55 | err = service.SetFavoriteArticle(favoriteArticle) 56 | if err != nil { 57 | return util.NewErrorResponse(err) 58 | } 59 | 60 | article, err := service.GetArticleByArticleId(articleId) 61 | if err != nil { 62 | return util.NewErrorResponse(err) 63 | } 64 | 65 | isFavorited, authors, following, err := service.GetArticleRelatedProperties(user, []model.Article{article}, true) 66 | if err != nil { 67 | return util.NewErrorResponse(err) 68 | } 69 | 70 | response := Response{ 71 | Article: ArticleResponse{ 72 | Slug: article.Slug, 73 | Title: article.Title, 74 | Description: article.Description, 75 | Body: article.Body, 76 | TagList: article.TagList, 77 | CreatedAt: time.Unix(0, article.CreatedAt).Format(model.TimestampFormat), 78 | UpdatedAt: time.Unix(0, article.UpdatedAt).Format(model.TimestampFormat), 79 | Favorited: isFavorited[0], 80 | FavoritesCount: article.FavoritesCount, 81 | Author: AuthorResponse{ 82 | Username: authors[0].Username, 83 | Bio: authors[0].Bio, 84 | Image: authors[0].Image, 85 | Following: following[0], 86 | }, 87 | }, 88 | } 89 | 90 | return util.NewSuccessResponse(200, response) 91 | } 92 | 93 | func main() { 94 | lambda.Start(Handle) 95 | } 96 | -------------------------------------------------------------------------------- /route/profiles-follow-delete/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 8 | ) 9 | 10 | type Response struct { 11 | Profile ProfileResponse `json:"profile"` 12 | } 13 | 14 | type ProfileResponse struct { 15 | Username string `json:"username"` 16 | Image string `json:"image"` 17 | Bio string `json:"bio"` 18 | Following bool `json:"following"` 19 | } 20 | 21 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 22 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 23 | if err != nil { 24 | return util.NewUnauthorizedResponse() 25 | } 26 | 27 | publisher, err := service.GetUserByUsername(input.PathParameters["username"]) 28 | if err != nil { 29 | return util.NewErrorResponse(err) 30 | } 31 | 32 | err = service.Unfollow(user.Username, publisher.Username) 33 | if err != nil { 34 | return util.NewErrorResponse(err) 35 | } 36 | 37 | response := Response{ 38 | Profile: ProfileResponse{ 39 | Username: publisher.Username, 40 | Image: publisher.Image, 41 | Bio: publisher.Bio, 42 | Following: false, 43 | }, 44 | } 45 | 46 | return util.NewSuccessResponse(200, response) 47 | } 48 | 49 | func main() { 50 | lambda.Start(Handle) 51 | } 52 | -------------------------------------------------------------------------------- /route/profiles-follow-post/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 8 | ) 9 | 10 | type Response struct { 11 | Profile ProfileResponse `json:"profile"` 12 | } 13 | 14 | type ProfileResponse struct { 15 | Username string `json:"username"` 16 | Image string `json:"image"` 17 | Bio string `json:"bio"` 18 | Following bool `json:"following"` 19 | } 20 | 21 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 22 | user, _, err := service.GetCurrentUser(input.Headers["Authorization"]) 23 | if err != nil { 24 | return util.NewUnauthorizedResponse() 25 | } 26 | 27 | publisher, err := service.GetUserByUsername(input.PathParameters["username"]) 28 | if err != nil { 29 | return util.NewErrorResponse(err) 30 | } 31 | 32 | err = service.Follow(user.Username, publisher.Username) 33 | if err != nil { 34 | return util.NewErrorResponse(err) 35 | } 36 | 37 | response := Response{ 38 | Profile: ProfileResponse{ 39 | Username: publisher.Username, 40 | Image: publisher.Image, 41 | Bio: publisher.Bio, 42 | Following: true, 43 | }, 44 | } 45 | 46 | return util.NewSuccessResponse(200, response) 47 | } 48 | 49 | func main() { 50 | lambda.Start(Handle) 51 | } 52 | -------------------------------------------------------------------------------- /route/profiles-get/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 8 | ) 9 | 10 | type Response struct { 11 | Profile ProfileResponse `json:"profile"` 12 | } 13 | 14 | type ProfileResponse struct { 15 | Username string `json:"username"` 16 | Image string `json:"image"` 17 | Bio string `json:"bio"` 18 | Following bool `json:"following"` 19 | } 20 | 21 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 22 | user, _, _ := service.GetCurrentUser(input.Headers["Authorization"]) 23 | 24 | publisher, err := service.GetUserByUsername(input.PathParameters["username"]) 25 | if err != nil { 26 | return util.NewErrorResponse(err) 27 | } 28 | 29 | following, err := service.IsFollowing(user, []string{publisher.Username}) 30 | if err != nil { 31 | return util.NewErrorResponse(err) 32 | } 33 | 34 | response := Response{ 35 | Profile: ProfileResponse{ 36 | Username: publisher.Username, 37 | Image: publisher.Image, 38 | Bio: publisher.Bio, 39 | Following: following[0], 40 | }, 41 | } 42 | 43 | return util.NewSuccessResponse(200, response) 44 | } 45 | 46 | func main() { 47 | lambda.Start(Handle) 48 | } 49 | -------------------------------------------------------------------------------- /route/tags-get/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 8 | ) 9 | 10 | type Response struct { 11 | Tags []string `json:"tags"` 12 | } 13 | 14 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 15 | tags, err := service.GetTags() 16 | if err != nil { 17 | return util.NewErrorResponse(err) 18 | } 19 | 20 | response := Response{ 21 | Tags: tags, 22 | } 23 | 24 | return util.NewSuccessResponse(200, response) 25 | } 26 | 27 | func main() { 28 | lambda.Start(Handle) 29 | } 30 | -------------------------------------------------------------------------------- /route/user-get/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/aws/aws-lambda-go/events" 5 | "github.com/aws/aws-lambda-go/lambda" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 8 | ) 9 | 10 | type Response struct { 11 | User UserResponse `json:"user"` 12 | } 13 | 14 | type UserResponse struct { 15 | Username string `json:"username"` 16 | Email string `json:"email"` 17 | Image string `json:"image"` 18 | Bio string `json:"bio"` 19 | Token string `json:"token"` 20 | } 21 | 22 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 23 | user, token, err := service.GetCurrentUser(input.Headers["Authorization"]) 24 | if err != nil { 25 | return util.NewUnauthorizedResponse() 26 | } 27 | 28 | response := Response{ 29 | User: UserResponse{ 30 | Username: user.Username, 31 | Email: user.Email, 32 | Image: user.Image, 33 | Bio: user.Bio, 34 | Token: token, 35 | }, 36 | } 37 | 38 | return util.NewSuccessResponse(200, response) 39 | } 40 | 41 | func main() { 42 | lambda.Start(Handle) 43 | } 44 | -------------------------------------------------------------------------------- /route/user-put/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/aws/aws-lambda-go/events" 6 | "github.com/aws/aws-lambda-go/lambda" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 9 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 10 | ) 11 | 12 | type Request struct { 13 | User UserRequest `json:"user"` 14 | } 15 | 16 | type UserRequest struct { 17 | Email string `json:"email"` 18 | Password string `json:"password"` 19 | Image string `json:"image"` 20 | Bio string `json:"bio"` 21 | } 22 | 23 | type Response struct { 24 | User UserResponse `json:"user"` 25 | } 26 | 27 | type UserResponse struct { 28 | Username string `json:"username"` 29 | Email string `json:"email"` 30 | Image string `json:"image"` 31 | Bio string `json:"bio"` 32 | Token string `json:"token"` 33 | } 34 | 35 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 36 | oldUser, token, err := service.GetCurrentUser(input.Headers["Authorization"]) 37 | if err != nil { 38 | return util.NewUnauthorizedResponse() 39 | } 40 | 41 | request := Request{} 42 | err = json.Unmarshal([]byte(input.Body), &request) 43 | if err != nil { 44 | return util.NewErrorResponse(err) 45 | } 46 | 47 | err = model.ValidatePassword(request.User.Password) 48 | if err != nil { 49 | return util.NewErrorResponse(err) 50 | } 51 | 52 | passwordHash, err := model.Scrypt(request.User.Password) 53 | if err != nil { 54 | return util.NewErrorResponse(err) 55 | } 56 | 57 | newUser := model.User{ 58 | Username: oldUser.Username, 59 | Email: request.User.Email, 60 | PasswordHash: passwordHash, 61 | Image: request.User.Image, 62 | Bio: request.User.Bio, 63 | } 64 | 65 | err = service.UpdateUser(*oldUser, newUser) 66 | if err != nil { 67 | return util.NewErrorResponse(err) 68 | } 69 | 70 | response := Response{ 71 | User: UserResponse{ 72 | Username: newUser.Username, 73 | Email: newUser.Email, 74 | Image: newUser.Image, 75 | Bio: newUser.Bio, 76 | Token: token, 77 | }, 78 | } 79 | 80 | return util.NewSuccessResponse(200, response) 81 | } 82 | 83 | func main() { 84 | lambda.Start(Handle) 85 | } 86 | -------------------------------------------------------------------------------- /route/users-login-post/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "github.com/aws/aws-lambda-go/events" 7 | "github.com/aws/aws-lambda-go/lambda" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 9 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 10 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 11 | ) 12 | 13 | type Request struct { 14 | User UserRequest `json:"user"` 15 | } 16 | 17 | type UserRequest struct { 18 | Email string `json:"email"` 19 | Password string `json:"password"` 20 | } 21 | 22 | type Response struct { 23 | User UserResponse `json:"user"` 24 | } 25 | 26 | type UserResponse struct { 27 | Username string `json:"username"` 28 | Email string `json:"email"` 29 | Image string `json:"image"` 30 | Bio string `json:"bio"` 31 | Token string `json:"token"` 32 | } 33 | 34 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 35 | request := Request{} 36 | err := json.Unmarshal([]byte(input.Body), &request) 37 | if err != nil { 38 | return util.NewErrorResponse(err) 39 | } 40 | 41 | user, err := service.GetUserByEmail(request.User.Email) 42 | if err != nil { 43 | return util.NewErrorResponse(err) 44 | } 45 | 46 | passwordHash, err := model.Scrypt(request.User.Password) 47 | if err != nil { 48 | return util.NewErrorResponse(err) 49 | } 50 | 51 | if !bytes.Equal(passwordHash, user.PasswordHash) { 52 | return util.NewErrorResponse(model.NewInputError("password", "wrong password")) 53 | } 54 | 55 | token, err := model.GenerateToken(user.Username) 56 | if err != nil { 57 | return util.NewErrorResponse(err) 58 | } 59 | 60 | response := Response{ 61 | User: UserResponse{ 62 | Username: user.Username, 63 | Email: user.Email, 64 | Image: user.Image, 65 | Bio: user.Bio, 66 | Token: token, 67 | }, 68 | } 69 | 70 | return util.NewSuccessResponse(200, response) 71 | } 72 | 73 | func main() { 74 | lambda.Start(Handle) 75 | } 76 | -------------------------------------------------------------------------------- /route/users-post/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/aws/aws-lambda-go/events" 6 | "github.com/aws/aws-lambda-go/lambda" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/service" 9 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 10 | ) 11 | 12 | type Request struct { 13 | User UserRequest `json:"user"` 14 | } 15 | 16 | type UserRequest struct { 17 | Username string `json:"username"` 18 | Email string `json:"email"` 19 | Password string `json:"password"` 20 | } 21 | 22 | type Response struct { 23 | User UserResponse `json:"user"` 24 | } 25 | 26 | type UserResponse struct { 27 | Username string `json:"username"` 28 | Email string `json:"email"` 29 | Image string `json:"image"` 30 | Bio string `json:"bio"` 31 | Token string `json:"token"` 32 | } 33 | 34 | func Handle(input events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) { 35 | request := Request{} 36 | err := json.Unmarshal([]byte(input.Body), &request) 37 | if err != nil { 38 | return util.NewErrorResponse(err) 39 | } 40 | 41 | err = model.ValidatePassword(request.User.Password) 42 | if err != nil { 43 | return util.NewErrorResponse(err) 44 | } 45 | 46 | passwordHash, err := model.Scrypt(request.User.Password) 47 | if err != nil { 48 | return util.NewErrorResponse(err) 49 | } 50 | 51 | user := model.User{ 52 | Username: request.User.Username, 53 | Email: request.User.Email, 54 | PasswordHash: passwordHash, 55 | } 56 | 57 | err = service.PutUser(user) 58 | if err != nil { 59 | return util.NewErrorResponse(err) 60 | } 61 | 62 | token, err := model.GenerateToken(user.Username) 63 | if err != nil { 64 | return util.NewErrorResponse(err) 65 | } 66 | 67 | response := Response{ 68 | User: UserResponse{ 69 | Username: user.Username, 70 | Email: user.Email, 71 | Image: user.Image, 72 | Bio: user.Bio, 73 | Token: token, 74 | }, 75 | } 76 | 77 | return util.NewSuccessResponse(201, response) 78 | } 79 | 80 | func main() { 81 | lambda.Start(Handle) 82 | } 83 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: realworld 2 | 3 | frameworkVersion: '>=1.28.0 <2.0.0' 4 | 5 | provider: 6 | name: aws 7 | runtime: go1.x 8 | stage: ${opt:stage, "dev"} 9 | region: ${opt:region, "eu-west-1"} 10 | environment: 11 | STAGE: ${self:provider.stage} 12 | iamRoleStatements: 13 | - Effect: Allow 14 | Action: 15 | - dynamodb:BatchGetItem 16 | - dynamodb:DeleteItem 17 | - dynamodb:GetItem 18 | - dynamodb:PutItem 19 | - dynamodb:Query 20 | - dynamodb:UpdateItem 21 | Resource: "arn:aws:dynamodb:${self:provider.region}:*:table/*" 22 | 23 | package: 24 | exclude: 25 | - ./** 26 | include: 27 | - ./bin/** 28 | 29 | functions: 30 | 31 | users-post: 32 | handler: bin/users-post 33 | events: 34 | - http: 35 | path: users 36 | method: post 37 | cors: true 38 | 39 | users-login-post: 40 | handler: bin/users-login-post 41 | events: 42 | - http: 43 | path: users/login 44 | method: post 45 | cors: true 46 | 47 | user-get: 48 | handler: bin/user-get 49 | events: 50 | - http: 51 | path: user 52 | method: get 53 | cors: true 54 | 55 | user-put: 56 | handler: bin/user-put 57 | events: 58 | - http: 59 | path: user 60 | method: put 61 | cors: true 62 | 63 | profiles-get: 64 | handler: bin/profiles-get 65 | events: 66 | - http: 67 | path: profiles/{username} 68 | method: get 69 | cors: true 70 | request: 71 | parameters: 72 | paths: 73 | username: true 74 | 75 | profiles-follow-post: 76 | handler: bin/profiles-follow-post 77 | events: 78 | - http: 79 | path: profiles/{username}/follow 80 | method: post 81 | cors: true 82 | request: 83 | parameters: 84 | paths: 85 | username: true 86 | 87 | profiles-follow-delete: 88 | handler: bin/profiles-follow-delete 89 | events: 90 | - http: 91 | path: profiles/{username}/follow 92 | method: delete 93 | cors: true 94 | request: 95 | parameters: 96 | paths: 97 | username: true 98 | 99 | articles-post: 100 | handler: bin/articles-post 101 | events: 102 | - http: 103 | path: articles 104 | method: post 105 | cors: true 106 | 107 | articles-get: 108 | handler: bin/articles-get 109 | events: 110 | - http: 111 | path: articles 112 | method: get 113 | cors: true 114 | 115 | articles-feed-get: 116 | handler: bin/articles-feed-get 117 | events: 118 | - http: 119 | path: articles/feed 120 | method: get 121 | cors: true 122 | 123 | articles-slug-get: 124 | handler: bin/articles-slug-get 125 | events: 126 | - http: 127 | path: articles/{slug} 128 | method: get 129 | cors: true 130 | request: 131 | parameters: 132 | paths: 133 | slug: true 134 | 135 | articles-slug-put: 136 | handler: bin/articles-slug-put 137 | events: 138 | - http: 139 | path: articles/{slug} 140 | method: put 141 | cors: true 142 | request: 143 | parameters: 144 | paths: 145 | slug: true 146 | 147 | articles-slug-delete: 148 | handler: bin/articles-slug-delete 149 | events: 150 | - http: 151 | path: articles/{slug} 152 | method: delete 153 | cors: true 154 | request: 155 | parameters: 156 | paths: 157 | slug: true 158 | 159 | comments-post: 160 | handler: bin/comments-post 161 | events: 162 | - http: 163 | path: articles/{slug}/comments 164 | method: post 165 | cors: true 166 | request: 167 | parameters: 168 | paths: 169 | slug: true 170 | 171 | comments-get: 172 | handler: bin/comments-get 173 | events: 174 | - http: 175 | path: articles/{slug}/comments 176 | method: get 177 | cors: true 178 | request: 179 | parameters: 180 | paths: 181 | slug: true 182 | 183 | comments-delete: 184 | handler: bin/comments-delete 185 | events: 186 | - http: 187 | path: articles/{slug}/comments/{id} 188 | method: delete 189 | cors: true 190 | request: 191 | parameters: 192 | paths: 193 | slug: true 194 | id: true 195 | 196 | favorite-post: 197 | handler: bin/favorite-post 198 | events: 199 | - http: 200 | path: articles/{slug}/favorite 201 | method: post 202 | cors: true 203 | request: 204 | parameters: 205 | paths: 206 | slug: true 207 | 208 | favorite-delete: 209 | handler: bin/favorite-delete 210 | events: 211 | - http: 212 | path: articles/{slug}/favorite 213 | method: delete 214 | cors: true 215 | request: 216 | parameters: 217 | paths: 218 | slug: true 219 | 220 | tags-get: 221 | handler: bin/tags-get 222 | events: 223 | - http: 224 | path: tags 225 | method: get 226 | cors: true 227 | 228 | resources: 229 | Resources: 230 | UserTable: 231 | Type: AWS::DynamoDB::Table 232 | Properties: 233 | TableName: realworld-${self:provider.stage}-user 234 | AttributeDefinitions: 235 | - AttributeName: Username 236 | AttributeType: S 237 | KeySchema: # GET /user, GET /profiles/:username 238 | - AttributeName: Username 239 | KeyType: HASH 240 | BillingMode: PROVISIONED 241 | ProvisionedThroughput: 242 | ReadCapacityUnits: 2 243 | WriteCapacityUnits: 2 244 | 245 | EmailUserTable: 246 | Type: AWS::DynamoDB::Table 247 | Properties: 248 | TableName: realworld-${self:provider.stage}-email-user 249 | AttributeDefinitions: 250 | - AttributeName: Email 251 | AttributeType: S 252 | KeySchema: # POST /users/login 253 | - AttributeName: Email 254 | KeyType: HASH 255 | BillingMode: PROVISIONED 256 | ProvisionedThroughput: 257 | ReadCapacityUnits: 2 258 | WriteCapacityUnits: 2 259 | 260 | FollowTable: 261 | Type: AWS::DynamoDB::Table 262 | Properties: 263 | TableName: realworld-${self:provider.stage}-follow 264 | AttributeDefinitions: 265 | - AttributeName: Follower 266 | AttributeType: S 267 | - AttributeName: Publisher 268 | AttributeType: S 269 | KeySchema: # GET /articles/feed 270 | - AttributeName: Follower 271 | KeyType: HASH 272 | - AttributeName: Publisher 273 | KeyType: RANGE 274 | BillingMode: PROVISIONED 275 | ProvisionedThroughput: 276 | ReadCapacityUnits: 2 277 | WriteCapacityUnits: 2 278 | 279 | ArticleTable: 280 | Type: AWS::DynamoDB::Table 281 | Properties: 282 | TableName: realworld-${self:provider.stage}-article 283 | AttributeDefinitions: 284 | - AttributeName: ArticleId 285 | AttributeType: N 286 | - AttributeName: CreatedAt 287 | AttributeType: N 288 | - AttributeName: Dummy 289 | AttributeType: N 290 | - AttributeName: Author 291 | AttributeType: S 292 | KeySchema: # GET /articles/:slug 293 | - AttributeName: ArticleId 294 | KeyType: HASH 295 | GlobalSecondaryIndexes: 296 | - IndexName: CreatedAt 297 | KeySchema: # GET /articles 298 | - AttributeName: Dummy 299 | KeyType: HASH 300 | - AttributeName: CreatedAt 301 | KeyType: RANGE 302 | Projection: 303 | ProjectionType: ALL 304 | ProvisionedThroughput: 305 | ReadCapacityUnits: 2 306 | WriteCapacityUnits: 2 307 | - IndexName: Author 308 | KeySchema: # GET /articles?author=:author 309 | - AttributeName: Author 310 | KeyType: HASH 311 | - AttributeName: CreatedAt 312 | KeyType: RANGE 313 | Projection: 314 | ProjectionType: ALL 315 | ProvisionedThroughput: 316 | ReadCapacityUnits: 2 317 | WriteCapacityUnits: 2 318 | BillingMode: PROVISIONED 319 | ProvisionedThroughput: 320 | ReadCapacityUnits: 2 321 | WriteCapacityUnits: 2 322 | 323 | ArticleTagTable: 324 | Type: AWS::DynamoDB::Table 325 | Properties: 326 | TableName: realworld-${self:provider.stage}-article-tag 327 | AttributeDefinitions: 328 | - AttributeName: Tag 329 | AttributeType: S 330 | - AttributeName: ArticleId 331 | AttributeType: N 332 | - AttributeName: CreatedAt 333 | AttributeType: N 334 | KeySchema: # POST /articles, PUT /articles 335 | - AttributeName: Tag 336 | KeyType: HASH 337 | - AttributeName: ArticleId 338 | KeyType: RANGE 339 | LocalSecondaryIndexes: 340 | - IndexName: CreatedAt 341 | KeySchema: # GET /articles?tag=:tag 342 | - AttributeName: Tag 343 | KeyType: HASH 344 | - AttributeName: CreatedAt 345 | KeyType: RANGE 346 | Projection: 347 | ProjectionType: ALL 348 | BillingMode: PROVISIONED 349 | ProvisionedThroughput: 350 | ReadCapacityUnits: 2 351 | WriteCapacityUnits: 2 352 | 353 | TagTable: 354 | Type: AWS::DynamoDB::Table 355 | Properties: 356 | TableName: realworld-${self:provider.stage}-tag 357 | AttributeDefinitions: 358 | - AttributeName: Tag 359 | AttributeType: S 360 | - AttributeName: ArticleCount 361 | AttributeType: N 362 | - AttributeName: Dummy 363 | AttributeType: N 364 | KeySchema: 365 | - AttributeName: Tag 366 | KeyType: HASH 367 | GlobalSecondaryIndexes: 368 | - IndexName: ArticleCount 369 | KeySchema: # GET /tags 370 | - AttributeName: Dummy 371 | KeyType: HASH 372 | - AttributeName: ArticleCount 373 | KeyType: RANGE 374 | Projection: 375 | ProjectionType: ALL 376 | ProvisionedThroughput: 377 | ReadCapacityUnits: 2 378 | WriteCapacityUnits: 2 379 | BillingMode: PROVISIONED 380 | ProvisionedThroughput: 381 | ReadCapacityUnits: 2 382 | WriteCapacityUnits: 2 383 | 384 | FavoriteArticleTable: 385 | Type: AWS::DynamoDB::Table 386 | Properties: 387 | TableName: realworld-${self:provider.stage}-favorite-article 388 | AttributeDefinitions: 389 | - AttributeName: Username 390 | AttributeType: S 391 | - AttributeName: ArticleId 392 | AttributeType: N 393 | - AttributeName: FavoritedAt 394 | AttributeType: N 395 | KeySchema: # POST /articles/:slug/favorite 396 | - AttributeName: Username 397 | KeyType: HASH 398 | - AttributeName: ArticleId 399 | KeyType: RANGE 400 | LocalSecondaryIndexes: 401 | - IndexName: FavoritedAt 402 | KeySchema: # GET /articles?favorited=:favorited 403 | - AttributeName: Username 404 | KeyType: HASH 405 | - AttributeName: FavoritedAt 406 | KeyType: RANGE 407 | Projection: 408 | ProjectionType: ALL 409 | BillingMode: PROVISIONED 410 | ProvisionedThroughput: 411 | ReadCapacityUnits: 2 412 | WriteCapacityUnits: 2 413 | 414 | CommentTable: 415 | Type: AWS::DynamoDB::Table 416 | Properties: 417 | TableName: realworld-${self:provider.stage}-comment 418 | AttributeDefinitions: 419 | - AttributeName: ArticleId 420 | AttributeType: N 421 | - AttributeName: CommentId 422 | AttributeType: N 423 | - AttributeName: CreatedAt 424 | AttributeType: N 425 | KeySchema: # POST /articles/:slug/comments 426 | - AttributeName: ArticleId 427 | KeyType: HASH 428 | - AttributeName: CommentId 429 | KeyType: RANGE 430 | LocalSecondaryIndexes: 431 | - IndexName: CreatedAt 432 | KeySchema: # GET /articles/:slug/comments 433 | - AttributeName: ArticleId 434 | KeyType: HASH 435 | - AttributeName: CreatedAt 436 | KeyType: RANGE 437 | Projection: 438 | ProjectionType: ALL 439 | BillingMode: PROVISIONED 440 | ProvisionedThroughput: 441 | ReadCapacityUnits: 2 442 | WriteCapacityUnits: 2 443 | -------------------------------------------------------------------------------- /service/ArticleService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/service/dynamodb" 8 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 9 | "github.com/aws/aws-sdk-go/service/dynamodb/expression" 10 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 11 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 12 | ) 13 | 14 | func PutArticle(article *model.Article) error { 15 | err := article.Validate() 16 | if err != nil { 17 | return err 18 | } 19 | 20 | const maxAttempt = 5 21 | 22 | // Try to find a unique article id 23 | for attempt := 0; ; attempt++ { 24 | err := putArticleWithRandomId(article) 25 | 26 | if err == nil { 27 | return nil 28 | } 29 | 30 | if attempt >= maxAttempt { 31 | return err 32 | } 33 | 34 | if !IsConditionalCheckFailed(err) { 35 | return err 36 | } 37 | 38 | ArticleIdRand.RenewSeed() 39 | } 40 | } 41 | 42 | func putArticleWithRandomId(article *model.Article) error { 43 | article.ArticleId = 1 + ArticleIdRand.Get().Int63n(model.MaxArticleId-1) // range: [1, MaxArticleId) 44 | article.MakeSlug() 45 | 46 | articleItem, err := dynamodbattribute.MarshalMap(article) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | transactItems := make([]*dynamodb.TransactWriteItem, 0, 1+2*len(article.TagList)) 52 | 53 | // Put a new article 54 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 55 | Put: &dynamodb.Put{ 56 | TableName: aws.String(ArticleTableName), 57 | Item: articleItem, 58 | ConditionExpression: aws.String("attribute_not_exists(ArticleId)"), 59 | }, 60 | }) 61 | 62 | for _, tag := range article.TagList { 63 | articleTag := model.ArticleTag{ 64 | Tag: tag, 65 | ArticleId: article.ArticleId, 66 | CreatedAt: article.CreatedAt, 67 | } 68 | 69 | item, err := dynamodbattribute.MarshalMap(articleTag) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | // Link article with tag 75 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 76 | Put: &dynamodb.Put{ 77 | TableName: aws.String(ArticleTagTableName), 78 | Item: item, 79 | }, 80 | }) 81 | 82 | // Update article count for each tag 83 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 84 | Update: &dynamodb.Update{ 85 | TableName: aws.String(TagTableName), 86 | Key: StringKey("Tag", tag), 87 | UpdateExpression: aws.String("ADD ArticleCount :one SET Dummy=:zero"), 88 | ExpressionAttributeValues: AWSObject{ 89 | ":one": IntValue(1), 90 | ":zero": IntValue(0), 91 | }, 92 | }, 93 | }) 94 | } 95 | 96 | _, err = DynamoDB().TransactWriteItems(&dynamodb.TransactWriteItemsInput{ 97 | TransactItems: transactItems, 98 | }) 99 | 100 | return err 101 | } 102 | 103 | func GetArticles(offset, limit int, author, tag, favorited string) ([]model.Article, error) { 104 | if offset < 0 { 105 | return nil, model.NewInputError("offset", "must be non-negative") 106 | } 107 | 108 | if limit <= 0 { 109 | return nil, model.NewInputError("limit", "must be positive") 110 | } 111 | 112 | const maxDepth = 1000 113 | if offset+limit > maxDepth { 114 | return nil, model.NewInputError("offset + limit", fmt.Sprintf("must be smaller or equal to %d", maxDepth)) 115 | } 116 | 117 | numFilters := getNumFilters(author, tag, favorited) 118 | if numFilters > 1 { 119 | return nil, model.NewInputError("author, tag, favorited", "only one of these can be specified") 120 | } 121 | 122 | if numFilters == 0 { 123 | return getAllArticles(offset, limit) 124 | } 125 | 126 | if author != "" { 127 | return getArticlesByAuthor(author, offset, limit) 128 | } 129 | 130 | if tag != "" { 131 | return getArticlesByTag(tag, offset, limit) 132 | } 133 | 134 | if favorited != "" { 135 | return getFavoriteArticlesByUsername(favorited, offset, limit) 136 | } 137 | 138 | return nil, errors.New("unreachable code") 139 | } 140 | 141 | func getNumFilters(author, tag, favorited string) int { 142 | numFilters := 0 143 | if author != "" { 144 | numFilters++ 145 | } 146 | if tag != "" { 147 | numFilters++ 148 | } 149 | if favorited != "" { 150 | numFilters++ 151 | } 152 | return numFilters 153 | } 154 | 155 | func getAllArticles(offset, limit int) ([]model.Article, error) { 156 | queryArticles := dynamodb.QueryInput{ 157 | TableName: aws.String(ArticleTableName), 158 | IndexName: aws.String("CreatedAt"), 159 | KeyConditionExpression: aws.String("Dummy=:zero"), 160 | ExpressionAttributeValues: IntKey(":zero", 0), 161 | Limit: aws.Int64(int64(offset + limit)), 162 | ScanIndexForward: aws.Bool(false), 163 | } 164 | 165 | items, err := QueryItems(&queryArticles, offset, limit) 166 | if err != nil { 167 | return nil, err 168 | } 169 | 170 | articles := make([]model.Article, len(items)) 171 | err = dynamodbattribute.UnmarshalListOfMaps(items, &articles) 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | return articles, nil 177 | } 178 | 179 | func getArticlesByAuthor(author string, offset, limit int) ([]model.Article, error) { 180 | queryArticles := dynamodb.QueryInput{ 181 | TableName: aws.String(ArticleTableName), 182 | IndexName: aws.String("Author"), 183 | KeyConditionExpression: aws.String("Author=:author"), 184 | ExpressionAttributeValues: StringKey(":author", author), 185 | Limit: aws.Int64(int64(offset + limit)), 186 | ScanIndexForward: aws.Bool(false), 187 | } 188 | 189 | items, err := QueryItems(&queryArticles, offset, limit) 190 | if err != nil { 191 | return nil, err 192 | } 193 | 194 | articles := make([]model.Article, len(items)) 195 | err = dynamodbattribute.UnmarshalListOfMaps(items, &articles) 196 | if err != nil { 197 | return nil, err 198 | } 199 | 200 | return articles, nil 201 | } 202 | 203 | func getArticlesByTag(tag string, offset, limit int) ([]model.Article, error) { 204 | articleIds, err := GetArticleIdsByTag(tag, offset, limit) 205 | if err != nil { 206 | return nil, err 207 | } 208 | 209 | return getArticlesByArticleIds(articleIds, limit) 210 | } 211 | 212 | func getFavoriteArticlesByUsername(username string, offset, limit int) ([]model.Article, error) { 213 | articleIds, err := GetFavoriteArticleIdsByUsername(username, offset, limit) 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | return getArticlesByArticleIds(articleIds, limit) 219 | } 220 | 221 | func getArticlesByArticleIds(articleIds []int64, limit int) ([]model.Article, error) { 222 | if len(articleIds) == 0 { 223 | return make([]model.Article, 0), nil 224 | } 225 | 226 | keys := make([]AWSObject, 0, len(articleIds)) 227 | for _, articleId := range articleIds { 228 | keys = append(keys, Int64Key("ArticleId", articleId)) 229 | } 230 | 231 | batchGetArticles := dynamodb.BatchGetItemInput{ 232 | RequestItems: map[string]*dynamodb.KeysAndAttributes{ 233 | ArticleTableName: { 234 | Keys: keys, 235 | }, 236 | }, 237 | } 238 | 239 | responses, err := BatchGetItems(&batchGetArticles, limit) 240 | if err != nil { 241 | return nil, err 242 | } 243 | 244 | articles := make([]model.Article, len(articleIds)) 245 | articleIdToIndex := ReverseIndexInt64(articleIds) 246 | 247 | for _, response := range responses { 248 | for _, items := range response { 249 | for _, item := range items { 250 | article := model.Article{} 251 | err = dynamodbattribute.UnmarshalMap(item, &article) 252 | if err != nil { 253 | return nil, err 254 | } 255 | 256 | index := articleIdToIndex[article.ArticleId] 257 | articles[index] = article 258 | } 259 | } 260 | } 261 | 262 | return articles, nil 263 | } 264 | 265 | func GetArticleRelatedProperties(user *model.User, articles []model.Article, getFollowing bool) ([]bool, []model.User, []bool, error) { 266 | isFavorited, err := IsArticleFavoritedByUser(user, articles) 267 | if err != nil { 268 | return nil, nil, nil, err 269 | } 270 | 271 | authorUsernames := make([]string, 0, len(articles)) 272 | for _, article := range articles { 273 | authorUsernames = append(authorUsernames, article.Author) 274 | } 275 | 276 | authors, err := GetUserListByUsername(authorUsernames) 277 | if err != nil { 278 | return nil, nil, nil, err 279 | } 280 | 281 | following := make([]bool, 0) 282 | 283 | if getFollowing { 284 | following, err = IsFollowing(user, authorUsernames) 285 | if err != nil { 286 | return nil, nil, nil, err 287 | } 288 | } 289 | 290 | return isFavorited, authors, following, nil 291 | } 292 | 293 | func GetArticleBySlug(slug string) (model.Article, error) { 294 | articleId, err := model.SlugToArticleId(slug) 295 | if err != nil { 296 | return model.Article{}, err 297 | } 298 | 299 | return GetArticleByArticleId(articleId) 300 | } 301 | 302 | func GetArticleByArticleId(articleId int64) (model.Article, error) { 303 | article := model.Article{} 304 | found, err := GetItemByKey(ArticleTableName, Int64Key("ArticleId", articleId), &article) 305 | 306 | if err != nil { 307 | return model.Article{}, err 308 | } 309 | 310 | if !found { 311 | return model.Article{}, model.NewInputError("slug", "not found") 312 | } 313 | 314 | return article, nil 315 | } 316 | 317 | func UpdateArticle(oldArticle model.Article, newArticle *model.Article) error { 318 | err := newArticle.Validate() 319 | if err != nil { 320 | return err 321 | } 322 | 323 | newArticle.MakeSlug() 324 | 325 | oldTagSet := util.NewStringSetFromSlice(oldArticle.TagList) 326 | newTagSet := util.NewStringSetFromSlice(newArticle.TagList) 327 | oldTags := oldTagSet.Difference(newTagSet) 328 | newTags := newTagSet.Difference(oldTagSet) 329 | 330 | transactItems := make([]*dynamodb.TransactWriteItem, 0, 1+2*len(oldTags)+2*len(newTags)) 331 | 332 | expr, err := buildArticleUpdateExpression(oldArticle, *newArticle, len(oldTags) != 0 || len(newTags) != 0) 333 | if err != nil { 334 | return err 335 | } 336 | 337 | // No field changed 338 | if expr.Update() == nil { 339 | return nil 340 | } 341 | 342 | // Update article 343 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 344 | Update: &dynamodb.Update{ 345 | TableName: aws.String(ArticleTableName), 346 | Key: Int64Key("ArticleId", oldArticle.ArticleId), 347 | ConditionExpression: aws.String("attribute_exists(ArticleId)"), 348 | UpdateExpression: expr.Update(), 349 | ExpressionAttributeNames: expr.Names(), 350 | ExpressionAttributeValues: expr.Values(), 351 | }, 352 | }) 353 | 354 | for tag := range oldTags { 355 | // Unlink article from tag 356 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 357 | Delete: &dynamodb.Delete{ 358 | TableName: aws.String(ArticleTagTableName), 359 | Key: AWSObject{ 360 | "Tag": StringValue(tag), 361 | "ArticleId": Int64Value(oldArticle.ArticleId), 362 | }, 363 | }, 364 | }) 365 | 366 | // Update article count for each tag 367 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 368 | Update: &dynamodb.Update{ 369 | TableName: aws.String(TagTableName), 370 | Key: StringKey("Tag", tag), 371 | UpdateExpression: aws.String("ADD ArticleCount :minus_one"), 372 | ExpressionAttributeValues: IntKey(":minus_one", -1), 373 | }, 374 | }) 375 | } 376 | 377 | for tag := range newTags { 378 | articleTag := model.ArticleTag{ 379 | Tag: tag, 380 | ArticleId: oldArticle.ArticleId, 381 | CreatedAt: oldArticle.CreatedAt, 382 | } 383 | 384 | item, err := dynamodbattribute.MarshalMap(articleTag) 385 | if err != nil { 386 | return err 387 | } 388 | 389 | // Link article with tag. 390 | // Ignored benign race condition: 391 | // Current tag list: A B C 392 | // Request 1: A B (Delete C) 393 | // Request 2: A B C D (Add D) 394 | // There's a small chance for both requests to get through, leading to inconsistent result A B D 395 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 396 | Put: &dynamodb.Put{ 397 | TableName: aws.String(ArticleTagTableName), 398 | Item: item, 399 | }, 400 | }) 401 | 402 | // Update article count for each tag 403 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 404 | Update: &dynamodb.Update{ 405 | TableName: aws.String(TagTableName), 406 | Key: StringKey("Tag", tag), 407 | UpdateExpression: aws.String("ADD ArticleCount :one SET Dummy=:zero"), 408 | ExpressionAttributeValues: AWSObject{ 409 | ":one": IntValue(1), 410 | ":zero": IntValue(0), 411 | }, 412 | }, 413 | }) 414 | } 415 | 416 | _, err = DynamoDB().TransactWriteItems(&dynamodb.TransactWriteItemsInput{ 417 | TransactItems: transactItems, 418 | }) 419 | if err != nil { 420 | return err 421 | } 422 | 423 | return nil 424 | } 425 | 426 | func buildArticleUpdateExpression(oldArticle model.Article, newArticle model.Article, updateTagList bool) (expression.Expression, error) { 427 | update := expression.UpdateBuilder{} 428 | 429 | if oldArticle.Slug != newArticle.Slug { 430 | update = update.Set(expression.Name("Slug"), expression.Value(newArticle.Slug)) 431 | } 432 | 433 | if oldArticle.Title != newArticle.Title { 434 | update = update.Set(expression.Name("Title"), expression.Value(newArticle.Title)) 435 | } 436 | 437 | if oldArticle.Description != newArticle.Description { 438 | update = update.Set(expression.Name("Description"), expression.Value(newArticle.Description)) 439 | } 440 | 441 | if oldArticle.Body != newArticle.Body { 442 | update = update.Set(expression.Name("Body"), expression.Value(newArticle.Body)) 443 | } 444 | 445 | if updateTagList { 446 | update = update.Set(expression.Name("TagList"), expression.Value(newArticle.TagList)) 447 | } 448 | 449 | if oldArticle.UpdatedAt != newArticle.UpdatedAt { 450 | update = update.Set(expression.Name("UpdatedAt"), expression.Value(newArticle.UpdatedAt)) 451 | } 452 | 453 | if IsUpdateBuilderEmpty(update) { 454 | return expression.Expression{}, nil 455 | } 456 | 457 | builder := expression.NewBuilder().WithUpdate(update) 458 | return builder.Build() 459 | } 460 | 461 | func DeleteArticle(slug string, username string) error { 462 | article, err := GetArticleBySlug(slug) 463 | if err != nil { 464 | return err 465 | } 466 | 467 | transactItems := make([]*dynamodb.TransactWriteItem, 0, 3+2*len(article.TagList)) 468 | 469 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 470 | Delete: &dynamodb.Delete{ 471 | TableName: aws.String(ArticleTableName), 472 | Key: Int64Key("ArticleId", article.ArticleId), 473 | ConditionExpression: aws.String("Author=:username"), 474 | ExpressionAttributeValues: StringKey(":username", username), 475 | }, 476 | }) 477 | 478 | // TODO: DynamoDB doesn't support deleting a whole partition by specifying just the partition key. 479 | // https://stackoverflow.com/questions/34259358/dynamodb-delete-all-items-having-same-hash-key 480 | // It's probably easier to delete related items in FavoriteArticleTable and CommentTable 481 | // offline (despite potential article id overwrite). 482 | 483 | for _, tag := range article.TagList { 484 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 485 | Delete: &dynamodb.Delete{ 486 | TableName: aws.String(ArticleTagTableName), 487 | Key: AWSObject{ 488 | "Tag": StringValue(tag), 489 | "ArticleId": Int64Value(article.ArticleId), 490 | }, 491 | }, 492 | }) 493 | 494 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 495 | Update: &dynamodb.Update{ 496 | TableName: aws.String(TagTableName), 497 | Key: StringKey("Tag", tag), 498 | UpdateExpression: aws.String("ADD ArticleCount :minus_one"), 499 | ExpressionAttributeValues: IntKey(":minus_one", -1), 500 | }, 501 | }) 502 | } 503 | 504 | _, err = DynamoDB().TransactWriteItems(&dynamodb.TransactWriteItemsInput{ 505 | TransactItems: transactItems, 506 | }) 507 | if err != nil { 508 | return err 509 | } 510 | 511 | return nil 512 | } 513 | 514 | func GetFeed(username string, offset, limit int) ([]model.Article, error) { 515 | queryPublishers := dynamodb.QueryInput{ 516 | TableName: aws.String(FollowTableName), 517 | KeyConditionExpression: aws.String("Follower=:username"), 518 | ExpressionAttributeValues: StringKey(":username", username), 519 | ProjectionExpression: aws.String("Publisher"), 520 | } 521 | 522 | const queryInitialCapacity = 16 523 | items, err := QueryItems(&queryPublishers, 0, queryInitialCapacity) 524 | if err != nil { 525 | return nil, err 526 | } 527 | 528 | follows := make([]model.Follow, 0, len(items)) 529 | err = dynamodbattribute.UnmarshalListOfMaps(items, &follows) 530 | if err != nil { 531 | return nil, err 532 | } 533 | 534 | // TODO: DynamoDB doesn't support batch queries 535 | // https://stackoverflow.com/questions/24953783/dynamodb-batch-execute-queryrequests 536 | // Concurrent queries can probably improve the performance of the following operations. 537 | 538 | articlesByAuthor := make(model.ArticlePriorityQueue, 0, len(follows)) 539 | 540 | for _, follow := range follows { 541 | articles, err := getArticlesByAuthor(follow.Publisher, 0, limit) 542 | if err != nil { 543 | return nil, err 544 | } 545 | 546 | articlesByAuthor = append(articlesByAuthor, articles) 547 | } 548 | 549 | return model.MergeArticles(articlesByAuthor, offset, limit), nil 550 | } 551 | -------------------------------------------------------------------------------- /service/ArticleTagService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | ) 9 | 10 | func GetArticleIdsByTag(tag string, offset, limit int) ([]int64, error) { 11 | queryArticleIds := dynamodb.QueryInput{ 12 | TableName: aws.String(ArticleTagTableName), 13 | IndexName: aws.String("CreatedAt"), 14 | KeyConditionExpression: aws.String("Tag=:tag"), 15 | ExpressionAttributeValues: StringKey(":tag", tag), 16 | Limit: aws.Int64(int64(offset + limit)), 17 | ScanIndexForward: aws.Bool(false), 18 | ProjectionExpression: aws.String("ArticleId"), 19 | } 20 | 21 | items, err := QueryItems(&queryArticleIds, offset, limit) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | articleTags := make([]model.ArticleTag, len(items)) 27 | err = dynamodbattribute.UnmarshalListOfMaps(items, &articleTags) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | articleIds := make([]int64, 0, len(items)) 33 | 34 | for _, articleTag := range articleTags { 35 | articleIds = append(articleIds, articleTag.ArticleId) 36 | } 37 | 38 | return articleIds, nil 39 | } 40 | -------------------------------------------------------------------------------- /service/CommentService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | ) 9 | 10 | func PutComment(comment *model.Comment) error { 11 | err := comment.Validate() 12 | if err != nil { 13 | return err 14 | } 15 | 16 | const maxAttempt = 5 17 | 18 | // Try to find a unique comment id 19 | for attempt := 0; ; attempt++ { 20 | err := putCommentWithRandomId(comment) 21 | 22 | if err == nil { 23 | return nil 24 | } 25 | 26 | if attempt >= maxAttempt { 27 | return err 28 | } 29 | 30 | if !IsConditionalCheckFailed(err) { 31 | return err 32 | } 33 | 34 | CommentIdRand.RenewSeed() 35 | } 36 | } 37 | 38 | func putCommentWithRandomId(comment *model.Comment) error { 39 | comment.CommentId = 1 + CommentIdRand.Get().Int63n(model.MaxCommentId-1) // range: [1, MaxCommentId) 40 | 41 | commentItem, err := dynamodbattribute.MarshalMap(comment) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | // Put a new article 47 | _, err = DynamoDB().PutItem(&dynamodb.PutItemInput{ 48 | TableName: aws.String(CommentTableName), 49 | Item: commentItem, 50 | ConditionExpression: aws.String("attribute_not_exists(CommentId)"), 51 | }) 52 | 53 | return err 54 | } 55 | 56 | func GetCommentRelatedProperties(user *model.User, comments []model.Comment) ([]model.User, []bool, error) { 57 | authorUsernames := make([]string, 0, len(comments)) 58 | for _, comment := range comments { 59 | authorUsernames = append(authorUsernames, comment.Author) 60 | } 61 | 62 | authors, err := GetUserListByUsername(authorUsernames) 63 | if err != nil { 64 | return nil, nil, err 65 | } 66 | 67 | following, err := IsFollowing(user, authorUsernames) 68 | if err != nil { 69 | return nil, nil, err 70 | } 71 | 72 | return authors, following, nil 73 | } 74 | 75 | func GetComments(slug string) ([]model.Comment, error) { 76 | articleId, err := model.SlugToArticleId(slug) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | queryComments := dynamodb.QueryInput{ 82 | TableName: aws.String(CommentTableName), 83 | IndexName: aws.String("CreatedAt"), 84 | KeyConditionExpression: aws.String("ArticleId=:articleId"), 85 | ExpressionAttributeValues: Int64Key(":articleId", articleId), 86 | ScanIndexForward: aws.Bool(false), 87 | } 88 | 89 | const queryInitialCapacity = 16 90 | items, err := QueryItems(&queryComments, 0, queryInitialCapacity) 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | comments := make([]model.Comment, len(items)) 96 | err = dynamodbattribute.UnmarshalListOfMaps(items, &comments) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return comments, nil 102 | } 103 | 104 | func DeleteComment(slug string, commentId int64, username string) error { 105 | articleId, err := model.SlugToArticleId(slug) 106 | if err != nil { 107 | return err 108 | } 109 | 110 | key := model.CommentKey{ 111 | ArticleId: articleId, 112 | CommentId: commentId, 113 | } 114 | 115 | item, err := dynamodbattribute.MarshalMap(key) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | deleteComment := dynamodb.DeleteItemInput{ 121 | TableName: aws.String(CommentTableName), 122 | Key: item, 123 | ConditionExpression: aws.String("Author=:username"), 124 | ExpressionAttributeValues: StringKey(":username", username), 125 | } 126 | 127 | _, err = DynamoDB().DeleteItem(&deleteComment) 128 | 129 | return err 130 | } 131 | -------------------------------------------------------------------------------- /service/CommonDBOperation.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/util" 8 | ) 9 | 10 | func GetItemByKey(tableName string, key AWSObject, out interface{}) (bool, error) { 11 | input := dynamodb.GetItemInput{ 12 | TableName: aws.String(tableName), 13 | Key: key, 14 | } 15 | 16 | output, err := DynamoDB().GetItem(&input) 17 | if err != nil { 18 | return false, err 19 | } 20 | 21 | if len(output.Item) == 0 { 22 | return false, nil 23 | } 24 | 25 | err = dynamodbattribute.UnmarshalMap(output.Item, out) 26 | if err != nil { 27 | return false, err 28 | } 29 | 30 | return true, nil 31 | } 32 | 33 | func QueryItems(queryInput *dynamodb.QueryInput, offset, cap int) ([]AWSObject, error) { 34 | items := make([]AWSObject, 0, cap) 35 | resultIndex := 0 36 | 37 | err := DynamoDB().QueryPages(queryInput, func(page *dynamodb.QueryOutput, lastPage bool) bool { 38 | pageCount := len(page.Items) 39 | 40 | if resultIndex+pageCount > offset { 41 | start := util.MaxInt(0, offset-resultIndex) 42 | for i := start; i < pageCount; i++ { 43 | items = append(items, page.Items[i]) 44 | } 45 | } 46 | 47 | resultIndex += pageCount 48 | return true 49 | }) 50 | 51 | if err != nil { 52 | return nil, err 53 | } 54 | 55 | return items, nil 56 | } 57 | 58 | func BatchGetItems(batchGetInput *dynamodb.BatchGetItemInput, cap int) ([]map[string][]AWSObject, error) { 59 | responses := make([]map[string][]AWSObject, 0, cap) 60 | 61 | err := DynamoDB().BatchGetItemPages(batchGetInput, func(page *dynamodb.BatchGetItemOutput, lastPage bool) bool { 62 | responses = append(responses, page.Responses) 63 | return true 64 | }) 65 | 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return responses, nil 71 | } 72 | -------------------------------------------------------------------------------- /service/DynamoDBClient.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws/session" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | "sync" 7 | ) 8 | 9 | var once sync.Once 10 | var svc *dynamodb.DynamoDB 11 | 12 | func initializeSingletons() { 13 | sess := session.Must(session.NewSessionWithOptions(session.Options{ 14 | SharedConfigState: session.SharedConfigEnable, 15 | })) 16 | 17 | svc = dynamodb.New(sess) 18 | } 19 | 20 | func DynamoDB() *dynamodb.DynamoDB { 21 | once.Do(initializeSingletons) 22 | return svc 23 | } 24 | -------------------------------------------------------------------------------- /service/FavoriteArticleService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | ) 9 | 10 | func GetFavoriteArticleIdsByUsername(username string, offset, limit int) ([]int64, error) { 11 | queryArticleIds := dynamodb.QueryInput{ 12 | TableName: aws.String(FavoriteArticleTableName), 13 | IndexName: aws.String("FavoritedAt"), 14 | KeyConditionExpression: aws.String("Username=:username"), 15 | ExpressionAttributeValues: StringKey(":username", username), 16 | Limit: aws.Int64(int64(offset + limit)), 17 | ScanIndexForward: aws.Bool(false), 18 | ProjectionExpression: aws.String("ArticleId"), 19 | } 20 | 21 | items, err := QueryItems(&queryArticleIds, offset, limit) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | favoriteArticles := make([]model.FavoriteArticle, len(items)) 27 | err = dynamodbattribute.UnmarshalListOfMaps(items, &favoriteArticles) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | articleIds := make([]int64, 0, len(items)) 33 | 34 | for _, favoriteArticle := range favoriteArticles { 35 | articleIds = append(articleIds, favoriteArticle.ArticleId) 36 | } 37 | 38 | return articleIds, nil 39 | } 40 | 41 | func IsArticleFavoritedByUser(user *model.User, articles []model.Article) ([]bool, error) { 42 | if user == nil || len(articles) == 0 { 43 | return make([]bool, len(articles)), nil 44 | } 45 | 46 | keys := make([]AWSObject, 0, len(articles)) 47 | for _, article := range articles { 48 | keys = append(keys, AWSObject{ 49 | "Username": StringValue(user.Username), 50 | "ArticleId": Int64Value(article.ArticleId), 51 | }) 52 | } 53 | 54 | batchGetFavoriteArticles := dynamodb.BatchGetItemInput{ 55 | RequestItems: map[string]*dynamodb.KeysAndAttributes{ 56 | FavoriteArticleTableName: { 57 | Keys: keys, 58 | ProjectionExpression: aws.String("ArticleId"), 59 | }, 60 | }, 61 | } 62 | 63 | responses, err := BatchGetItems(&batchGetFavoriteArticles, len(articles)) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | isFavorited := make([]bool, len(articles)) 69 | articleIdToIndex := reverseIndexArticleIds(articles) 70 | 71 | for _, response := range responses { 72 | for _, items := range response { 73 | for _, item := range items { 74 | favoriteArticle := model.FavoriteArticle{} 75 | err = dynamodbattribute.UnmarshalMap(item, &favoriteArticle) 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | index := articleIdToIndex[favoriteArticle.ArticleId] 81 | isFavorited[index] = true 82 | } 83 | } 84 | } 85 | 86 | return isFavorited, nil 87 | } 88 | 89 | func reverseIndexArticleIds(articles []model.Article) map[int64]int { 90 | indices := make(map[int64]int) 91 | for i, article := range articles { 92 | indices[article.ArticleId] = i 93 | } 94 | return indices 95 | } 96 | 97 | func SetFavoriteArticle(favoriteArticle model.FavoriteArticle) error { 98 | item, err := dynamodbattribute.MarshalMap(favoriteArticle) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | transactItems := make([]*dynamodb.TransactWriteItem, 0, 2) 104 | 105 | // Favorite the article 106 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 107 | Put: &dynamodb.Put{ 108 | TableName: aws.String(FavoriteArticleTableName), 109 | Item: item, 110 | ConditionExpression: aws.String("attribute_not_exists(Username) AND attribute_not_exists(ArticleId)"), 111 | }, 112 | }) 113 | 114 | // Update favorites count 115 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 116 | Update: &dynamodb.Update{ 117 | TableName: aws.String(ArticleTableName), 118 | Key: Int64Key("ArticleId", favoriteArticle.ArticleId), 119 | ConditionExpression: aws.String("attribute_exists(ArticleId)"), 120 | UpdateExpression: aws.String("ADD FavoritesCount :one"), 121 | ExpressionAttributeValues: IntKey(":one", 1), 122 | }, 123 | }) 124 | 125 | _, err = DynamoDB().TransactWriteItems(&dynamodb.TransactWriteItemsInput{ 126 | TransactItems: transactItems, 127 | }) 128 | 129 | if err != nil { 130 | return model.NewInputError("slug", "not found or already favorited") 131 | } 132 | 133 | return nil 134 | } 135 | 136 | func UnfavoriteArticle(favoriteArticle model.FavoriteArticleKey) error { 137 | item, err := dynamodbattribute.MarshalMap(favoriteArticle) 138 | if err != nil { 139 | return err 140 | } 141 | 142 | transactItems := make([]*dynamodb.TransactWriteItem, 0, 2) 143 | 144 | // Unfavorite the article 145 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 146 | Delete: &dynamodb.Delete{ 147 | TableName: aws.String(FavoriteArticleTableName), 148 | Key: item, 149 | ConditionExpression: aws.String("attribute_exists(Username) AND attribute_exists(ArticleId)"), 150 | }, 151 | }) 152 | 153 | // Update favorites count 154 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 155 | Update: &dynamodb.Update{ 156 | TableName: aws.String(ArticleTableName), 157 | Key: Int64Key("ArticleId", favoriteArticle.ArticleId), 158 | ConditionExpression: aws.String("attribute_exists(ArticleId)"), 159 | UpdateExpression: aws.String("ADD FavoritesCount :minus_one"), 160 | ExpressionAttributeValues: IntKey(":minus_one", -1), 161 | }, 162 | }) 163 | 164 | _, err = DynamoDB().TransactWriteItems(&dynamodb.TransactWriteItemsInput{ 165 | TransactItems: transactItems, 166 | }) 167 | 168 | if err != nil { 169 | return model.NewInputError("slug", "not found or not favorited") 170 | } 171 | 172 | return nil 173 | } 174 | -------------------------------------------------------------------------------- /service/FollowService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | ) 9 | 10 | func IsFollowing(follower *model.User, publishers []string) ([]bool, error) { 11 | if follower == nil || len(publishers) == 0 { 12 | return make([]bool, len(publishers)), nil 13 | } 14 | 15 | publisherSet := make(map[string]bool) 16 | for _, publisher := range publishers { 17 | publisherSet[publisher] = true 18 | } 19 | 20 | keys := make([]AWSObject, 0, len(publisherSet)) 21 | for publisher := range publisherSet { 22 | keys = append(keys, AWSObject{ 23 | "Follower": StringValue(follower.Username), 24 | "Publisher": StringValue(publisher), 25 | }) 26 | } 27 | 28 | batchGetFollows := dynamodb.BatchGetItemInput{ 29 | RequestItems: map[string]*dynamodb.KeysAndAttributes{ 30 | FollowTableName: { 31 | Keys: keys, 32 | ProjectionExpression: aws.String("Publisher"), 33 | }, 34 | }, 35 | } 36 | 37 | responses, err := BatchGetItems(&batchGetFollows, len(publisherSet)) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | followingUser := make(map[string]bool) 43 | 44 | for _, response := range responses { 45 | for _, items := range response { 46 | for _, item := range items { 47 | follow := model.Follow{} 48 | err = dynamodbattribute.UnmarshalMap(item, &follow) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | followingUser[follow.Publisher] = true 54 | } 55 | } 56 | } 57 | 58 | following := make([]bool, 0, len(publishers)) 59 | for _, username := range publishers { 60 | following = append(following, followingUser[username]) 61 | } 62 | 63 | return following, nil 64 | } 65 | 66 | func Follow(follower string, publisher string) error { 67 | follow := model.Follow{ 68 | Follower: follower, 69 | Publisher: publisher, 70 | } 71 | 72 | item, err := dynamodbattribute.MarshalMap(follow) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | putFollow := dynamodb.PutItemInput{ 78 | TableName: aws.String(FollowTableName), 79 | Item: item, 80 | } 81 | 82 | _, err = DynamoDB().PutItem(&putFollow) 83 | 84 | return err 85 | } 86 | 87 | func Unfollow(follower string, publisher string) error { 88 | follow := model.Follow{ 89 | Follower: follower, 90 | Publisher: publisher, 91 | } 92 | 93 | item, err := dynamodbattribute.MarshalMap(follow) 94 | if err != nil { 95 | return err 96 | } 97 | 98 | deleteFollow := dynamodb.DeleteItemInput{ 99 | TableName: aws.String(FollowTableName), 100 | Key: item, 101 | } 102 | 103 | _, err = DynamoDB().DeleteItem(&deleteFollow) 104 | 105 | return err 106 | } 107 | -------------------------------------------------------------------------------- /service/Rand.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | var ArticleIdRand = NewRand() 9 | var CommentIdRand = NewRand() 10 | 11 | type Rand struct { 12 | random *rand.Rand 13 | } 14 | 15 | func NewRand() Rand { 16 | r := Rand{} 17 | r.RenewSeed() 18 | return r 19 | } 20 | 21 | func (r *Rand) RenewSeed() { 22 | r.random = rand.New(rand.NewSource(time.Now().UnixNano())) 23 | } 24 | 25 | func (r *Rand) Get() *rand.Rand { 26 | return r.random 27 | } 28 | -------------------------------------------------------------------------------- /service/TableName.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | var Stage = os.Getenv("STAGE") 9 | 10 | var UserTableName = makeTableName("user") 11 | var EmailUserTableName = makeTableName("email-user") 12 | var FollowTableName = makeTableName("follow") 13 | var ArticleTableName = makeTableName("article") 14 | var ArticleTagTableName = makeTableName("article-tag") 15 | var TagTableName = makeTableName("tag") 16 | var FavoriteArticleTableName = makeTableName("favorite-article") 17 | var CommentTableName = makeTableName("comment") 18 | 19 | func makeTableName(suffix string) string { 20 | return fmt.Sprintf("realworld-%s-%s", Stage, suffix) 21 | } 22 | -------------------------------------------------------------------------------- /service/TagService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | ) 9 | 10 | func GetTags() ([]string, error) { 11 | const maxNumTags = 20 12 | 13 | queryTags := dynamodb.QueryInput{ 14 | TableName: aws.String(TagTableName), 15 | IndexName: aws.String("ArticleCount"), 16 | KeyConditionExpression: aws.String("Dummy=:zero"), 17 | ExpressionAttributeValues: IntKey(":zero", 0), 18 | Limit: aws.Int64(maxNumTags), 19 | ScanIndexForward: aws.Bool(false), 20 | } 21 | 22 | items, err := QueryItems(&queryTags, 0, maxNumTags) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | tagObjects := make([]model.Tag, len(items)) 28 | err = dynamodbattribute.UnmarshalListOfMaps(items, &tagObjects) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | tags := make([]string, 0, len(tagObjects)) 34 | for _, tagObject := range tagObjects { 35 | if tagObject.ArticleCount > 0 { 36 | tags = append(tags, tagObject.Tag) 37 | } 38 | } 39 | 40 | return tags, nil 41 | } 42 | -------------------------------------------------------------------------------- /service/UserService.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/service/dynamodb" 6 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute" 7 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 8 | ) 9 | 10 | func PutUser(user model.User) error { 11 | err := user.Validate() 12 | if err != nil { 13 | return err 14 | } 15 | 16 | userItem, err := dynamodbattribute.MarshalMap(user) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | emailUser := model.EmailUser{ 22 | Email: user.Email, 23 | Username: user.Username, 24 | } 25 | 26 | emailUserItem, err := dynamodbattribute.MarshalMap(emailUser) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | // Put a new user, make sure username and email are unique 32 | transaction := dynamodb.TransactWriteItemsInput{ 33 | TransactItems: []*dynamodb.TransactWriteItem{ 34 | { 35 | Put: &dynamodb.Put{ 36 | TableName: aws.String(UserTableName), 37 | Item: userItem, 38 | ConditionExpression: aws.String("attribute_not_exists(Username)"), 39 | }, 40 | }, 41 | { 42 | Put: &dynamodb.Put{ 43 | TableName: aws.String(EmailUserTableName), 44 | Item: emailUserItem, 45 | ConditionExpression: aws.String("attribute_not_exists(Email)"), 46 | }, 47 | }, 48 | }, 49 | } 50 | 51 | _, err = DynamoDB().TransactWriteItems(&transaction) 52 | if err != nil { 53 | // TODO: distinguish: 54 | // NewInputError("username", "has already been taken") 55 | // NewInputError("email", "has already been taken") 56 | return err 57 | } 58 | 59 | return nil 60 | } 61 | 62 | func UpdateUser(oldUser model.User, newUser model.User) error { 63 | err := newUser.Validate() 64 | if err != nil { 65 | return err 66 | } 67 | 68 | transactItems := make([]*dynamodb.TransactWriteItem, 0, 3) 69 | 70 | if oldUser.Email != newUser.Email { 71 | newEmailUser := model.EmailUser{ 72 | Email: newUser.Email, 73 | Username: newUser.Username, 74 | } 75 | 76 | newEmailUserItem, err := dynamodbattribute.MarshalMap(newEmailUser) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | // Link user with the new email 82 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 83 | Put: &dynamodb.Put{ 84 | TableName: aws.String(EmailUserTableName), 85 | Item: newEmailUserItem, 86 | ConditionExpression: aws.String("attribute_not_exists(Email)"), 87 | }, 88 | }) 89 | 90 | // Unlink user from the old email 91 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 92 | Delete: &dynamodb.Delete{ 93 | TableName: aws.String(EmailUserTableName), 94 | Key: StringKey("Email", oldUser.Email), 95 | ConditionExpression: aws.String("attribute_exists(Email)"), 96 | }, 97 | }) 98 | } 99 | 100 | newUserItem, err := dynamodbattribute.MarshalMap(newUser) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | // Update user info 106 | transactItems = append(transactItems, &dynamodb.TransactWriteItem{ 107 | Put: &dynamodb.Put{ 108 | TableName: aws.String(UserTableName), 109 | Item: newUserItem, 110 | ConditionExpression: aws.String("Email = :email"), 111 | ExpressionAttributeValues: StringKey(":email", oldUser.Email), 112 | }, 113 | }) 114 | 115 | _, err = DynamoDB().TransactWriteItems(&dynamodb.TransactWriteItemsInput{ 116 | TransactItems: transactItems, 117 | }) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | return nil 123 | } 124 | 125 | func GetUserByEmail(email string) (model.User, error) { 126 | if email == "" { 127 | return model.User{}, model.NewInputError("email", "can't be blank") 128 | } 129 | 130 | username, err := GetUsernameByEmail(email) 131 | if err != nil { 132 | return model.User{}, err 133 | } 134 | 135 | return GetUserByUsername(username) 136 | } 137 | 138 | func GetUsernameByEmail(email string) (string, error) { 139 | emailUser := model.EmailUser{} 140 | found, err := GetItemByKey(EmailUserTableName, StringKey("Email", email), &emailUser) 141 | 142 | if err != nil { 143 | return "", err 144 | } 145 | 146 | if !found { 147 | return "", model.NewInputError("email", "not found") 148 | } 149 | 150 | return emailUser.Username, nil 151 | } 152 | 153 | func GetUserByUsername(username string) (model.User, error) { 154 | if username == "" { 155 | return model.User{}, model.NewInputError("username", "can't be blank") 156 | } 157 | 158 | user := model.User{} 159 | found, err := GetItemByKey(UserTableName, StringKey("Username", username), &user) 160 | 161 | if err != nil { 162 | return model.User{}, err 163 | } 164 | 165 | if !found { 166 | return model.User{}, model.NewInputError("username", "not found") 167 | } 168 | 169 | return user, err 170 | } 171 | 172 | func GetCurrentUser(auth string) (*model.User, string, error) { 173 | username, token, err := model.VerifyAuthorization(auth) 174 | if err != nil { 175 | return nil, "", err 176 | } 177 | 178 | user, err := GetUserByUsername(username) 179 | if err != nil { 180 | return nil, "", err 181 | } 182 | 183 | return &user, token, nil 184 | } 185 | 186 | func GetUserListByUsername(usernames []string) ([]model.User, error) { 187 | if len(usernames) == 0 { 188 | return make([]model.User, 0), nil 189 | } 190 | 191 | usernameSet := make(map[string]bool) 192 | for _, username := range usernames { 193 | usernameSet[username] = true 194 | } 195 | 196 | keys := make([]AWSObject, 0, len(usernameSet)) 197 | for username := range usernameSet { 198 | keys = append(keys, StringKey("Username", username)) 199 | } 200 | 201 | batchGetUsers := dynamodb.BatchGetItemInput{ 202 | RequestItems: map[string]*dynamodb.KeysAndAttributes{ 203 | UserTableName: { 204 | Keys: keys, 205 | }, 206 | }, 207 | } 208 | 209 | responses, err := BatchGetItems(&batchGetUsers, len(usernames)) 210 | if err != nil { 211 | return nil, err 212 | } 213 | 214 | usersByUsername := make(map[string]model.User) 215 | 216 | for _, response := range responses { 217 | for _, items := range response { 218 | for _, item := range items { 219 | user := model.User{} 220 | err = dynamodbattribute.UnmarshalMap(item, &user) 221 | if err != nil { 222 | return nil, err 223 | } 224 | 225 | usersByUsername[user.Username] = user 226 | } 227 | } 228 | } 229 | 230 | users := make([]model.User, 0, len(usernames)) 231 | for _, username := range usernames { 232 | users = append(users, usersByUsername[username]) 233 | } 234 | 235 | return users, nil 236 | } 237 | -------------------------------------------------------------------------------- /service/Util.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/aws" 5 | "github.com/aws/aws-sdk-go/aws/awserr" 6 | "github.com/aws/aws-sdk-go/service/dynamodb" 7 | "github.com/aws/aws-sdk-go/service/dynamodb/expression" 8 | "reflect" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | type AWSObject = map[string]*dynamodb.AttributeValue 14 | 15 | func StringKey(name, value string) AWSObject { 16 | return AWSObject{ 17 | name: StringValue(value), 18 | } 19 | } 20 | 21 | func StringValue(value string) *dynamodb.AttributeValue { 22 | return &dynamodb.AttributeValue{ 23 | S: aws.String(value), 24 | } 25 | } 26 | 27 | func IntKey(name string, value int) AWSObject { 28 | return AWSObject{ 29 | name: IntValue(value), 30 | } 31 | } 32 | 33 | func IntValue(value int) *dynamodb.AttributeValue { 34 | return &dynamodb.AttributeValue{ 35 | N: aws.String(strconv.Itoa(value)), 36 | } 37 | } 38 | 39 | func Int64Key(name string, value int64) AWSObject { 40 | return AWSObject{ 41 | name: Int64Value(value), 42 | } 43 | } 44 | 45 | func Int64Value(value int64) *dynamodb.AttributeValue { 46 | return &dynamodb.AttributeValue{ 47 | N: aws.String(strconv.FormatInt(value, 10)), 48 | } 49 | } 50 | 51 | func BlobValue(value []byte) *dynamodb.AttributeValue { 52 | return &dynamodb.AttributeValue{ 53 | B: value, 54 | } 55 | } 56 | 57 | func ReverseIndexInt64(values []int64) map[int64]int { 58 | indices := make(map[int64]int) 59 | for i, v := range values { 60 | indices[v] = i 61 | } 62 | return indices 63 | } 64 | 65 | func IsUpdateBuilderEmpty(update expression.UpdateBuilder) bool { 66 | return reflect.ValueOf(&update).Elem().FieldByName("operationList").IsNil() 67 | } 68 | 69 | func IsConditionalCheckFailed(err error) bool { 70 | aerr, ok := err.(awserr.Error) 71 | if !ok { 72 | return false 73 | } 74 | 75 | switch aerr.Code() { 76 | case dynamodb.ErrCodeConditionalCheckFailedException: 77 | return true 78 | case dynamodb.ErrCodeTransactionCanceledException: 79 | // TODO: DynamoDB Go client doesn't provide individual cancellation reasons for a transaction. 80 | // "If using Java, DynamoDB lists the cancellation reasons on the CancellationReasons 81 | // property. This property is not set for other languages." 82 | // https://docs.aws.amazon.com/sdk-for-go/api/service/dynamodb/#DynamoDB.TransactWriteItems 83 | // Here we depend on awserr.Error.Message(), which looks like 84 | // "Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None]" 85 | // https://github.com/aws/aws-sdk-go/issues/2318 86 | return strings.Contains(aerr.Message(), "ConditionalCheckFailed") 87 | default: 88 | return false 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /service/Util_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/aws/aws-sdk-go/service/dynamodb/expression" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestIsUpdateBuilderEmpty(t *testing.T) { 10 | assert.True(t, IsUpdateBuilderEmpty(expression.UpdateBuilder{})) 11 | assert.False(t, IsUpdateBuilderEmpty(expression.Set(expression.Name("Name"), expression.Value("Value")))) 12 | } 13 | -------------------------------------------------------------------------------- /util/ErrorResponse.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/aws/aws-lambda-go/events" 6 | "github.com/chrisxue815/realworld-aws-lambda-dynamodb-go/model" 7 | ) 8 | 9 | type InputErrorResponse struct { 10 | Errors model.InputError `json:"errors"` 11 | } 12 | 13 | func NewErrorResponse(err error) (events.APIGatewayProxyResponse, error) { 14 | inputError, ok := err.(model.InputError) 15 | if !ok { 16 | // Internal server error 17 | return events.APIGatewayProxyResponse{}, err 18 | } 19 | 20 | body := InputErrorResponse{ 21 | Errors: inputError, 22 | } 23 | 24 | jsonBody, err := json.Marshal(body) 25 | if err != nil { 26 | return events.APIGatewayProxyResponse{}, err 27 | } 28 | 29 | response := events.APIGatewayProxyResponse{ 30 | StatusCode: 422, 31 | Body: string(jsonBody), 32 | Headers: CORSHeaders(), 33 | } 34 | return response, nil 35 | } 36 | 37 | func NewUnauthorizedResponse() (events.APIGatewayProxyResponse, error) { 38 | response := events.APIGatewayProxyResponse{ 39 | StatusCode: 401, 40 | Headers: CORSHeaders(), 41 | } 42 | return response, nil 43 | } 44 | -------------------------------------------------------------------------------- /util/Math.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func MaxInt(x, y int) int { 4 | if x < y { 5 | return y 6 | } 7 | return x 8 | } 9 | -------------------------------------------------------------------------------- /util/StringSet.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | type StringSet map[string]bool 4 | 5 | func NewStringSetFromSlice(slice []string) StringSet { 6 | s := make(StringSet, len(slice)) 7 | for _, value := range slice { 8 | s[value] = true 9 | } 10 | return s 11 | } 12 | 13 | func (s StringSet) Difference(other StringSet) StringSet { 14 | result := make(StringSet) 15 | for value := range s { 16 | if !other[value] { 17 | result[value] = true 18 | } 19 | } 20 | return result 21 | } 22 | 23 | func (s StringSet) ToSlice() []string { 24 | result := make([]string, 0, len(s)) 25 | for value := range s { 26 | result = append(result, value) 27 | } 28 | return result 29 | } 30 | -------------------------------------------------------------------------------- /util/SuccessResponse.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/aws/aws-lambda-go/events" 6 | ) 7 | 8 | func CORSHeaders() map[string]string { 9 | return map[string]string{ 10 | "Access-Control-Allow-Origin": "*", 11 | "Access-Control-Allow-Credentials": "true", 12 | } 13 | } 14 | 15 | func NewSuccessResponse(statusCode int, body interface{}) (events.APIGatewayProxyResponse, error) { 16 | response := events.APIGatewayProxyResponse{ 17 | StatusCode: statusCode, 18 | Headers: CORSHeaders(), 19 | } 20 | 21 | if body != nil { 22 | jsonBody, err := json.Marshal(body) 23 | if err != nil { 24 | return NewErrorResponse(err) 25 | } 26 | 27 | response.Body = string(jsonBody) 28 | } 29 | 30 | return response, nil 31 | } 32 | --------------------------------------------------------------------------------