├── .dockerignore ├── .gitignore ├── Dockerfile ├── Gopkg.lock ├── Gopkg.toml ├── Makefile ├── README.md ├── config ├── README.md └── config.go ├── db ├── README.md ├── docker │ └── init.sh └── schema.sql ├── docker-compose.yml ├── loader ├── README.md ├── bookmark.go ├── entry.go ├── loader.go └── user.go ├── main.go ├── model ├── README.md ├── bookmark.go ├── entry.go ├── error.go └── user.go ├── package.json ├── repository ├── README.md ├── bookmark.go ├── entry.go ├── repository.go └── user.go ├── resolver ├── README.md ├── bookmark_resolver.go ├── entry_resolver.go ├── handler.go ├── resolver.go ├── schema.graphql └── user_resolver.go ├── script └── localup ├── service ├── README.md ├── bookmark.go ├── bookmark_test.go ├── entry.go ├── entry_test.go ├── service.go ├── service_test.go ├── user.go └── user_test.go ├── templates ├── README.md ├── add.tmpl ├── bookmarks.tmpl ├── entry.tmpl ├── graphiql.tmpl ├── index.tmpl ├── main.tmpl ├── signin.tmpl ├── signup.tmpl └── spa.tmpl ├── titleFetcher ├── README.md └── title_fetcher.go ├── ui ├── README.md ├── src │ ├── components │ │ ├── __generated__ │ │ │ ├── BookmarkListFragment.ts │ │ │ ├── BookmarkListItemFragment.ts │ │ │ ├── CreateBookmark.ts │ │ │ ├── DeleteBookmark.ts │ │ │ ├── EntryBookmarkFragment.ts │ │ │ ├── EntryDetailFragment.ts │ │ │ ├── EntryListFragment.ts │ │ │ ├── EntryListItemFragment.ts │ │ │ ├── GetEntry.ts │ │ │ ├── GetVisitor.ts │ │ │ └── ListEntries.ts │ │ ├── add.tsx │ │ ├── app.tsx │ │ ├── bookmarks.tsx │ │ ├── entries.tsx │ │ ├── entry.tsx │ │ ├── entry_detail.tsx │ │ ├── global_header.tsx │ │ ├── index.tsx │ │ └── me.tsx │ ├── hello.spec.ts │ ├── hello.ts │ ├── index.scss │ ├── index.ts │ ├── spa.scss │ └── spa.tsx ├── tsconfig.json └── webpack.config.js ├── web ├── README.md ├── middleware.go ├── server.go ├── server_graphql_bookmark_test.go ├── server_graphql_entry_test.go ├── server_graphql_user_test.go ├── server_http_client_test.go └── server_test.go └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | /build 2 | /vendor 3 | *-gen.go 4 | 5 | /node_modules 6 | /static 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | /vendor 3 | *-gen.go 4 | 5 | /node_modules 6 | /static 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8 as node 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock ./ 6 | RUN yarn install 7 | 8 | COPY ui ui 9 | 10 | RUN yarn build 11 | 12 | 13 | FROM golang:1.10 14 | 15 | ENV WORKDIR /go/src/github.com/hatena/go-Intern-Bookmark 16 | WORKDIR $WORKDIR 17 | 18 | COPY Makefile Gopkg.toml Gopkg.lock ./ 19 | RUN make setup 20 | 21 | COPY . $WORKDIR 22 | 23 | COPY --from=node /app/static $WORKDIR/static 24 | 25 | CMD ["./script/localup"] 26 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | name = "github.com/davecgh/go-spew" 6 | packages = ["spew"] 7 | revision = "346938d642f2ec3594ed81d874461961cd0faa76" 8 | version = "v1.1.0" 9 | 10 | [[projects]] 11 | name = "github.com/dimfeld/httptreemux" 12 | packages = ["."] 13 | revision = "7f532489e7739b3d49df5c602bf63549881fe753" 14 | 15 | [[projects]] 16 | name = "github.com/go-sql-driver/mysql" 17 | packages = ["."] 18 | revision = "64db0f7ebe171b596aa9b26f39a79f7413a3b617" 19 | 20 | [[projects]] 21 | name = "github.com/graph-gophers/dataloader" 22 | packages = ["."] 23 | revision = "78139374585c29dcb97b8f33089ed11959e4be59" 24 | version = "v5" 25 | 26 | [[projects]] 27 | name = "github.com/graph-gophers/graphql-go" 28 | packages = [ 29 | ".", 30 | "errors", 31 | "internal/common", 32 | "internal/exec", 33 | "internal/exec/packer", 34 | "internal/exec/resolvable", 35 | "internal/exec/selected", 36 | "internal/query", 37 | "internal/schema", 38 | "internal/validation", 39 | "introspection", 40 | "log", 41 | "relay", 42 | "trace" 43 | ] 44 | revision = "e698b6abc17ead17ea89d3d22a4b2625299701e3" 45 | 46 | [[projects]] 47 | name = "github.com/jessevdk/go-assets" 48 | packages = ["."] 49 | revision = "4f4301a06e153ff90e17793577ab6bf79f8dc5c5" 50 | 51 | [[projects]] 52 | branch = "master" 53 | name = "github.com/jmoiron/sqlx" 54 | packages = [ 55 | ".", 56 | "reflectx" 57 | ] 58 | revision = "0dae4fefe7c0e190f7b5a78dac28a1c82cc8d849" 59 | 60 | [[projects]] 61 | name = "github.com/justinas/nosurf" 62 | packages = ["."] 63 | revision = "7182011986c42c33f0a79fd4b07e41edc784532b" 64 | 65 | [[projects]] 66 | name = "github.com/opentracing/opentracing-go" 67 | packages = [ 68 | ".", 69 | "ext", 70 | "log" 71 | ] 72 | revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" 73 | version = "v1.0.2" 74 | 75 | [[projects]] 76 | name = "github.com/pmezard/go-difflib" 77 | packages = ["difflib"] 78 | revision = "792786c7400a136282c1664665ae0a8db921c6c2" 79 | version = "v1.0.0" 80 | 81 | [[projects]] 82 | name = "github.com/stretchr/testify" 83 | packages = ["assert"] 84 | revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686" 85 | version = "v1.2.2" 86 | 87 | [[projects]] 88 | branch = "master" 89 | name = "golang.org/x/crypto" 90 | packages = [ 91 | "bcrypt", 92 | "blowfish" 93 | ] 94 | revision = "8ac0e0d97ce45cd83d1d7243c060cb8461dda5e9" 95 | 96 | [[projects]] 97 | branch = "master" 98 | name = "golang.org/x/net" 99 | packages = ["context"] 100 | revision = "db08ff08e8622530d9ed3a0e8ac279f6d4c02196" 101 | 102 | [[projects]] 103 | name = "google.golang.org/appengine" 104 | packages = ["cloudsql"] 105 | revision = "150dc57a1b433e64154302bdc40b6bb8aefa313a" 106 | version = "v1.0.0" 107 | 108 | [solve-meta] 109 | analyzer-name = "dep" 110 | analyzer-version = 1 111 | inputs-digest = "8793a4906c72f325884f6acfda463207327e60eac62b5ef3f6d92600749dc7b4" 112 | solver-name = "gps-cdcl" 113 | solver-version = 1 114 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | required = ["github.com/jessevdk/go-assets"] 2 | 3 | [[constraint]] 4 | name = "github.com/go-sql-driver/mysql" 5 | revision = "64db0f7ebe171b596aa9b26f39a79f7413a3b617" 6 | 7 | [[constraint]] 8 | name = "github.com/dimfeld/httptreemux" 9 | revision = "7f532489e7739b3d49df5c602bf63549881fe753" 10 | 11 | [[constraint]] 12 | name = "github.com/justinas/nosurf" 13 | revision = "7182011986c42c33f0a79fd4b07e41edc784532b" 14 | 15 | [[constraint]] 16 | name = "github.com/graph-gophers/graphql-go" 17 | revision = "e698b6abc17ead17ea89d3d22a4b2625299701e3" 18 | 19 | [[constraint]] 20 | name = "golang.org/x/crypto" 21 | branch = "master" 22 | 23 | [[constraint]] 24 | name = "github.com/jessevdk/go-assets" 25 | revision = "4f4301a06e153ff90e17793577ab6bf79f8dc5c5" 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN=server 2 | 3 | all: clean build 4 | 5 | build: deps 6 | go generate ./... 7 | go build -o build/$(BIN) . 8 | 9 | run: build 10 | build/$(BIN) 11 | 12 | setup: 13 | command -v dep >/dev/null || go get -u github.com/golang/dep/cmd/dep 14 | command -v go-assets-builder >/dev/null || go get -u github.com/jessevdk/go-assets-builder 15 | command -v reflex >/dev/null || go get -u github.com/cespare/reflex 16 | 17 | deps: 18 | dep ensure -vendor-only 19 | 20 | test: build 21 | DATABASE_DSN=$$DATABASE_DSN_TEST go test -v -p 1 ./... 22 | 23 | clean: 24 | rm -rf build */*-gen.go 25 | go clean 26 | 27 | .PHONY: build setup deps test clean 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-Intern-Bookmark 2 | ## ディレクトリー構成 3 | - db: データベースのテーブルスキーマ 4 | - config: 環境変数から読み込む設定 5 | - model: モデル層: 型定義を書きます 6 | - repository: データベースにアクセスするリポジトリ層 7 | - service: Bookmarkアプリケーションを定義するサービス層 8 | - web: webサーバーのルーティングやリクエストの解釈、レスポンスを実装するweb層 9 | - ui: フロントエンド 10 | - templates: HTMLテンプレート 11 | - resolver: GraphQLスキーマとクエリの実行 12 | - loader: GraphQL用のデータローダー 13 | 14 | ## 実行 15 | サーバー起動 16 | ```sh 17 | docker-compose up --build 18 | open http://localhost:8000 19 | ``` 20 | 21 | GraphiQL 22 | ``` 23 | open http://localhost:8000/graphiql 24 | ``` 25 | 26 | テスト実行 27 | ```sh 28 | docker-compose build && docker-compose run --rm app make test 29 | ``` 30 | または、手元のGoでテストする (早い) 31 | ```sh 32 | DATABASE_DSN_TEST=root@(localhost:3306)/intern_bookmark_test make test 33 | ``` 34 | 35 | MySQL 36 | ``` 37 | docker-compose exec db mysql 38 | mysql> use intern_bookmark; 39 | mysql> select * from user; 40 | ``` 41 | 42 | Dockerコンテナを作り直すために一旦削除 43 | ``` 44 | docker ps -a 45 | docker stop 46 | docker rm 47 | # または 48 | docker rm -f 49 | ``` 50 | 51 | 例: データベーススキーマを変更する時はmysqlコンテナを作り直す 52 | ``` 53 | docker ps -a | grep mysql 54 | docker rm -f 83ece5d09062 55 | ``` 56 | 57 | ## フロントエンド 58 | サーバーを起動すると自動的にwatchされる 59 | ``` 60 | docker-compose up 61 | ``` 62 | 63 | テスト実行 64 | ``` 65 | docker-compose run --rm node yarn test 66 | ``` 67 | -------------------------------------------------------------------------------- /config/README.md: -------------------------------------------------------------------------------- 1 | # config 2 | アプリケーションを動かすのに必要な設定を、環境変数から読み込みます。 3 | 4 | ```go 5 | type Config struct { 6 | Port int 7 | DbDsn string 8 | } 9 | 10 | func Load() (*Config, error) { 11 | // ... 12 | } 13 | ``` 14 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | ) 8 | 9 | type Config struct { 10 | Port int 11 | DbDsn string 12 | } 13 | 14 | func Load() (*Config, error) { 15 | config := &Config{ 16 | Port: 8000, 17 | } 18 | 19 | portStr := os.Getenv("PORT") 20 | if portStr != "" { 21 | port, err := strconv.Atoi(os.Getenv("PORT")) 22 | if err != nil { 23 | return nil, fmt.Errorf("Invalid PORT: %v", err) 24 | } 25 | config.Port = port 26 | } 27 | 28 | dbDsn := os.Getenv("DATABASE_DSN") 29 | if dbDsn == "" { 30 | return nil, fmt.Errorf("Specify DATABASE_DSN") 31 | } 32 | config.DbDsn = dbDsn 33 | 34 | return config, nil 35 | } 36 | -------------------------------------------------------------------------------- /db/README.md: -------------------------------------------------------------------------------- 1 | # データベースの設定 2 | テーブルスキーマを `schema.sql` に記述します。 3 | 4 | `PRIMARY KEY` や `UNIQUE KEY` は、アプリケーションの特性に応じて考えましょう。 5 | ```sql 6 | CREATE TABLE user ( 7 | `id` BIGINT UNSIGNED NOT NULL, 8 | 9 | `name` VARBINARY(32) NOT NULL, 10 | `password_hash` VARBINARY(254) NOT NULL, 11 | 12 | `created_at` DATETIME(6) NOT NULL, 13 | `updated_at` DATETIME(6) NOT NULL, 14 | 15 | PRIMARY KEY (id), 16 | UNIQUE KEY (name), 17 | 18 | KEY (created_at), 19 | KEY (updated_at) 20 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 21 | ``` 22 | 23 | `docker/init.sh` はdockerイメージの初期化時に自動で呼ばれます。 24 | -------------------------------------------------------------------------------- /db/docker/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xe 3 | 4 | mysqladmin -uroot create intern_bookmark 5 | mysqladmin -uroot create intern_bookmark_test 6 | 7 | mysql -uroot intern_bookmark < /app/db/schema.sql 8 | mysql -uroot intern_bookmark_test < /app/db/schema.sql 9 | -------------------------------------------------------------------------------- /db/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE user ( 2 | `id` BIGINT UNSIGNED NOT NULL, 3 | 4 | `name` VARBINARY(32) NOT NULL, 5 | `password_hash` VARBINARY(254) NOT NULL, 6 | 7 | `created_at` DATETIME(6) NOT NULL, 8 | `updated_at` DATETIME(6) NOT NULL, 9 | 10 | PRIMARY KEY (id), 11 | UNIQUE KEY (name), 12 | 13 | KEY (created_at), 14 | KEY (updated_at) 15 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 16 | 17 | CREATE TABLE user_session ( 18 | `user_id` BIGINT UNSIGNED NOT NULL, 19 | `token` VARBINARY(512) NOT NULL, 20 | 21 | `expires_at` DATETIME(6) NOT NULL, 22 | 23 | `created_at` DATETIME(6) NOT NULL, 24 | `updated_at` DATETIME(6) NOT NULL, 25 | 26 | PRIMARY KEY (token) 27 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 28 | 29 | CREATE TABLE entry ( 30 | `id` BIGINT UNSIGNED NOT NULL, 31 | 32 | `url` VARBINARY(512) NOT NULL, 33 | `title` VARCHAR(512) NOT NULL, 34 | 35 | `created_at` DATETIME(6) NOT NULL, 36 | `updated_at` DATETIME(6) NOT NULL, 37 | 38 | PRIMARY KEY (id), 39 | UNIQUE KEY (url(191)) 40 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 41 | 42 | CREATE TABLE bookmark ( 43 | `id` BIGINT UNSIGNED NOT NULL, 44 | 45 | `user_id` BIGINT UNSIGNED NOT NULL, 46 | `entry_id` BIGINT UNSIGNED NOT NULL, 47 | `comment` VARCHAR(256) NOT NULL, 48 | 49 | `created_at` DATETIME(6) NOT NULL, 50 | `updated_at` DATETIME(6) NOT NULL, 51 | 52 | PRIMARY KEY (id), 53 | UNIQUE KEY (user_id, entry_id), 54 | KEY (user_id, created_at) 55 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 56 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | services: 3 | app: 4 | build: . 5 | image: go-intern-bookmark 6 | volumes: 7 | - .:/go/src/github.com/hatena/go-Intern-Bookmark 8 | links: 9 | - db 10 | ports: 11 | - "8000:8000" 12 | environment: 13 | PORT: 8000 14 | DATABASE_DSN: root@(db:3306)/intern_bookmark 15 | DATABASE_DSN_TEST: root@(db:3306)/intern_bookmark_test 16 | tty: true 17 | stdin_open: true 18 | db: 19 | image: mysql:5.7 20 | volumes: 21 | - ./db/docker:/docker-entrypoint-initdb.d 22 | - .:/app 23 | ports: 24 | - "3306:3306" 25 | environment: 26 | - MYSQL_ALLOW_EMPTY_PASSWORD=1 27 | node: 28 | build: 29 | context: . 30 | target: node 31 | working_dir: /app 32 | command: ["yarn", "watch"] 33 | volumes: 34 | - ./ui:/app/ui 35 | - ./package.json:/app/package.json 36 | - ./yarn.lock:/app/yarn.lock 37 | - ./static:/app/static/ 38 | - yarn-cache:/usr/local/share/.cache/yarn/v1 39 | - node_modules:/app/node_modules 40 | volumes: 41 | yarn-cache: 42 | node_modules: 43 | -------------------------------------------------------------------------------- /loader/README.md: -------------------------------------------------------------------------------- 1 | # loader 2 | このパッケージでは、GraphQLのエンドポイントためのデータローダーを実装します。 3 | [graph-gophers/dataloader](https://godoc.org/github.com/graph-gophers/dataloader) パッケージを使います。 4 | 5 | GraphQLのリクエストからデータを引くまでの流れは次のようになります。 6 | 7 | - リクエストの `Context` に、各モデルの `dataloader.Loader` を保存する (`Attach`) 8 | - クエリに対してモデルに対する `Resolver` を返す 9 | - `Context` から引きたいモデルに対応する `dataloader.Loader` を探す (`getLoader`) 10 | - `dataloader.Loader` 経由でデータを引くと、 `dataloader.BatchFunc` が呼ばれてクエリをまとめることができる 11 | -------------------------------------------------------------------------------- /loader/bookmark.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/graph-gophers/dataloader" 9 | 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | "github.com/hatena/go-Intern-Bookmark/service" 12 | ) 13 | 14 | const bookmarkLoaderKey = "bookmarkLoader" 15 | 16 | type bookmarkIDKey struct { 17 | id uint64 18 | } 19 | 20 | func (key bookmarkIDKey) String() string { 21 | return fmt.Sprint(key.id) 22 | } 23 | 24 | func (key bookmarkIDKey) Raw() interface{} { 25 | return key.id 26 | } 27 | 28 | func LoadBookmark(ctx context.Context, id uint64) (*model.Bookmark, error) { 29 | ldr, err := getLoader(ctx, bookmarkLoaderKey) 30 | if err != nil { 31 | return nil, err 32 | } 33 | data, err := ldr.Load(ctx, bookmarkIDKey{id: id})() 34 | if err != nil { 35 | return nil, err 36 | } 37 | return data.(*model.Bookmark), nil 38 | } 39 | 40 | func LoadBookmarksByEntryID(ctx context.Context, id uint64) ([]*model.Bookmark, error) { 41 | ldr, err := getLoader(ctx, bookmarkLoaderKey) 42 | if err != nil { 43 | return nil, err 44 | } 45 | data, err := ldr.Load(ctx, entryIDKey{id: id})() 46 | if err != nil { 47 | return nil, err 48 | } 49 | return data.([]*model.Bookmark), nil 50 | } 51 | 52 | func LoadBookmarksByUserID(ctx context.Context, id uint64) ([]*model.Bookmark, error) { 53 | ldr, err := getLoader(ctx, bookmarkLoaderKey) 54 | if err != nil { 55 | return nil, err 56 | } 57 | data, err := ldr.Load(ctx, userIDKey{id: id})() 58 | if err != nil { 59 | return nil, err 60 | } 61 | return data.([]*model.Bookmark), nil 62 | } 63 | 64 | func newBookmarkLoader(app service.BookmarkApp) dataloader.BatchFunc { 65 | return func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result { 66 | results := make([]*dataloader.Result, len(keys)) 67 | bookmarkIDs := make([]uint64, 0, len(keys)) 68 | entryIDs := make([]uint64, 0, len(keys)) 69 | userIDs := make([]uint64, 0, len(keys)) 70 | for _, key := range keys { 71 | switch key := key.(type) { 72 | case bookmarkIDKey: 73 | bookmarkIDs = append(bookmarkIDs, key.id) 74 | case entryIDKey: 75 | entryIDs = append(entryIDs, key.id) 76 | case userIDKey: 77 | userIDs = append(userIDs, key.id) 78 | } 79 | } 80 | bookmarks, _ := app.ListBookmarksByIDs(bookmarkIDs) 81 | bookmarksByEntryIDs, _ := app.ListBookmarksByEntryIDs(entryIDs) 82 | bookmarksByUserIDs, _ := app.ListBookmarksByUserIDs(userIDs) 83 | for i, key := range keys { 84 | results[i] = &dataloader.Result{Data: nil, Error: nil} 85 | switch key := key.(type) { 86 | case bookmarkIDKey: 87 | for _, bookmark := range bookmarks { 88 | if key.id == bookmark.ID { 89 | results[i].Data = bookmark 90 | continue 91 | } 92 | } 93 | if results[i].Data == nil { 94 | results[i].Error = errors.New("bookmark not found") 95 | } 96 | case entryIDKey: 97 | results[i].Data = bookmarksByEntryIDs[key.id] 98 | case userIDKey: 99 | results[i].Data = bookmarksByUserIDs[key.id] 100 | } 101 | } 102 | return results 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /loader/entry.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/graph-gophers/dataloader" 9 | 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | "github.com/hatena/go-Intern-Bookmark/service" 12 | ) 13 | 14 | const entryLoaderKey = "entryLoader" 15 | 16 | type entryIDKey struct { 17 | id uint64 18 | } 19 | 20 | func (key entryIDKey) String() string { 21 | return fmt.Sprint(key.id) 22 | } 23 | 24 | func (key entryIDKey) Raw() interface{} { 25 | return key.id 26 | } 27 | 28 | func LoadEntry(ctx context.Context, id uint64) (*model.Entry, error) { 29 | ldr, err := getLoader(ctx, entryLoaderKey) 30 | if err != nil { 31 | return nil, err 32 | } 33 | data, err := ldr.Load(ctx, entryIDKey{id: id})() 34 | if err != nil { 35 | return nil, err 36 | } 37 | return data.(*model.Entry), nil 38 | } 39 | 40 | func newEntryLoader(app service.BookmarkApp) dataloader.BatchFunc { 41 | return func(ctx context.Context, entryIDKeys dataloader.Keys) []*dataloader.Result { 42 | results := make([]*dataloader.Result, len(entryIDKeys)) 43 | entryIDs := make([]uint64, len(entryIDKeys)) 44 | for i, key := range entryIDKeys { 45 | entryIDs[i] = key.(entryIDKey).id 46 | } 47 | entrys, _ := app.ListEntriesByIDs(entryIDs) 48 | for i, entryID := range entryIDs { 49 | results[i] = &dataloader.Result{Data: nil, Error: nil} 50 | for _, entry := range entrys { 51 | if entryID == entry.ID { 52 | results[i].Data = entry 53 | continue 54 | } 55 | } 56 | if results[i].Data == nil { 57 | results[i].Error = errors.New("entry not found") 58 | } 59 | } 60 | return results 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /loader/loader.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/graph-gophers/dataloader" 8 | 9 | "github.com/hatena/go-Intern-Bookmark/service" 10 | ) 11 | 12 | type Loaders interface { 13 | Attach(context.Context) context.Context 14 | } 15 | 16 | func New(app service.BookmarkApp) Loaders { 17 | return &loaders{ 18 | batchFuncs: map[string]dataloader.BatchFunc{ 19 | userLoaderKey: newUserLoader(app), 20 | bookmarkLoaderKey: newBookmarkLoader(app), 21 | entryLoaderKey: newEntryLoader(app), 22 | }, 23 | } 24 | } 25 | 26 | type loaders struct { 27 | batchFuncs map[string]dataloader.BatchFunc 28 | } 29 | 30 | func (c *loaders) Attach(ctx context.Context) context.Context { 31 | for key, batchFn := range c.batchFuncs { 32 | ctx = context.WithValue(ctx, key, dataloader.NewBatchedLoader(batchFn)) 33 | } 34 | return ctx 35 | } 36 | 37 | func getLoader(ctx context.Context, key string) (*dataloader.Loader, error) { 38 | ldr, ok := ctx.Value(key).(*dataloader.Loader) 39 | if !ok { 40 | return nil, fmt.Errorf("unable to find %s loader from the request context", key) 41 | } 42 | return ldr, nil 43 | } 44 | -------------------------------------------------------------------------------- /loader/user.go: -------------------------------------------------------------------------------- 1 | package loader 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/graph-gophers/dataloader" 9 | 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | "github.com/hatena/go-Intern-Bookmark/service" 12 | ) 13 | 14 | const userLoaderKey = "userLoader" 15 | 16 | type userIDKey struct { 17 | id uint64 18 | } 19 | 20 | func (key userIDKey) String() string { 21 | return fmt.Sprint(key.id) 22 | } 23 | 24 | func (key userIDKey) Raw() interface{} { 25 | return key.id 26 | } 27 | 28 | func LoadUser(ctx context.Context, id uint64) (*model.User, error) { 29 | ldr, err := getLoader(ctx, userLoaderKey) 30 | if err != nil { 31 | return nil, err 32 | } 33 | data, err := ldr.Load(ctx, userIDKey{id: id})() 34 | if err != nil { 35 | return nil, err 36 | } 37 | return data.(*model.User), nil 38 | } 39 | 40 | func newUserLoader(app service.BookmarkApp) dataloader.BatchFunc { 41 | return func(ctx context.Context, userIDKeys dataloader.Keys) []*dataloader.Result { 42 | results := make([]*dataloader.Result, len(userIDKeys)) 43 | userIDs := make([]uint64, len(userIDKeys)) 44 | for i, key := range userIDKeys { 45 | userIDs[i] = key.(userIDKey).id 46 | } 47 | users, _ := app.ListUsersByIDs(userIDs) 48 | for i, userID := range userIDs { 49 | results[i] = &dataloader.Result{Data: nil, Error: nil} 50 | for _, user := range users { 51 | if userID == user.ID { 52 | results[i].Data = user 53 | continue 54 | } 55 | } 56 | if results[i].Data == nil { 57 | results[i].Error = errors.New("user not found") 58 | } 59 | } 60 | return results 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "strconv" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/hatena/go-Intern-Bookmark/config" 14 | "github.com/hatena/go-Intern-Bookmark/repository" 15 | "github.com/hatena/go-Intern-Bookmark/service" 16 | "github.com/hatena/go-Intern-Bookmark/titleFetcher" 17 | "github.com/hatena/go-Intern-Bookmark/web" 18 | ) 19 | 20 | func main() { 21 | if err := run(os.Args); err != nil { 22 | fmt.Fprintf(os.Stderr, "%+v\n", err) 23 | os.Exit(1) 24 | } 25 | } 26 | 27 | func run(_ []string) error { 28 | conf, err := config.Load() 29 | if err != nil { 30 | return fmt.Errorf("failed to load config: %+v", err) 31 | } 32 | repo, err := repository.New(conf.DbDsn) 33 | if err != nil { 34 | return fmt.Errorf("failed to create repository: %+v", err) 35 | } 36 | app := service.NewApp(repo, titleFetcher.New()) 37 | server := &http.Server{ 38 | Addr: ":" + strconv.Itoa(conf.Port), 39 | Handler: web.NewServer(app).Handler(), 40 | } 41 | 42 | fmt.Printf("Starting server... (config: %#v)\n", conf) 43 | go graceful(server, 10*time.Second) 44 | if err = server.ListenAndServe(); err != http.ErrServerClosed { 45 | return err 46 | } 47 | if err = app.Close(); err != nil { 48 | return err 49 | } 50 | return nil 51 | } 52 | 53 | func graceful(server *http.Server, timeout time.Duration) { 54 | sigChan := make(chan os.Signal, 1) 55 | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) 56 | sig := <-sigChan 57 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 58 | defer cancel() 59 | fmt.Println("shutting down server...", sig) 60 | if err := server.Shutdown(ctx); err != nil { 61 | fmt.Printf("failed to shutdown: %v\n", err) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /model/README.md: -------------------------------------------------------------------------------- 1 | # モデル層 2 | 型定義を書きます。 3 | 4 | ```go 5 | type User struct { 6 | ID uint64 7 | Name string 8 | } 9 | ``` 10 | -------------------------------------------------------------------------------- /model/bookmark.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Bookmark struct { 4 | ID uint64 `db:"id"` 5 | UserID uint64 `db:"user_id"` 6 | EntryID uint64 `db:"entry_id"` 7 | Comment string `db:"comment"` 8 | } 9 | -------------------------------------------------------------------------------- /model/entry.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Entry struct { 4 | ID uint64 `db:"id"` 5 | URL string `db:"url"` 6 | Title string `db:"title"` 7 | } 8 | -------------------------------------------------------------------------------- /model/error.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type notFoundError string 4 | 5 | func (err notFoundError) Error() string { 6 | return string(err) + " not found" 7 | } 8 | 9 | func NotFoundError(typ string) error { 10 | return notFoundError(typ) 11 | } 12 | 13 | func IsNotFound(err error) bool { 14 | _, ok := err.(notFoundError) 15 | return ok 16 | } 17 | -------------------------------------------------------------------------------- /model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type User struct { 4 | ID uint64 `db:"id"` 5 | Name string `db:"name"` 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "watch": "webpack --config ui/webpack.config.js --watch", 7 | "build": "webpack --config ui/webpack.config.js --mode production", 8 | "test": "jest", 9 | "apollo-codegen": "apollo schema:download --endpoint=http://host.docker.internal:8000/query schema.json && apollo codegen:generate --schema=schema.json --queries=ui/src/**/*.tsx --target=typescript" 10 | }, 11 | "dependencies": { 12 | "apollo-cache-inmemory": "^1.2.5", 13 | "apollo-client": "^2.3.5", 14 | "apollo-link": "^1.2.2", 15 | "apollo-link-error": "^1.1.0", 16 | "apollo-link-http": "^1.5.4", 17 | "graphql": "^0.13.2", 18 | "graphql-tag": "^2.9.2", 19 | "react": "^16.4.1", 20 | "react-apollo": "^2.1.9", 21 | "react-dom": "^16.4.1", 22 | "react-router": "^4.3.1", 23 | "react-router-dom": "^4.3.1" 24 | }, 25 | "devDependencies": { 26 | "@types/jest": "^23.3.0", 27 | "@types/react": "^16.4.6", 28 | "@types/react-dom": "^16.0.6", 29 | "@types/react-router": "^4.0.29", 30 | "@types/react-router-dom": "^4.2.7", 31 | "apollo": "^1.4.0", 32 | "css-loader": "^1.0.0", 33 | "file-loader": "^1.1.11", 34 | "jest": "^23.4.1", 35 | "mini-css-extract-plugin": "^0.4.1", 36 | "node-sass": "^4.9.2", 37 | "sass-loader": "^7.0.3", 38 | "ts-jest": "^23.0.1", 39 | "ts-loader": "^4.4.2", 40 | "typescript": "^2.9.2", 41 | "webpack": "^4.16.1", 42 | "webpack-cli": "^3.0.8" 43 | }, 44 | "jest": { 45 | "moduleFileExtensions": [ 46 | "ts", 47 | "tsx", 48 | "js" 49 | ], 50 | "transform": { 51 | "^.+\\.(ts|tsx)$": "ts-jest" 52 | }, 53 | "globals": { 54 | "ts-jest": { 55 | "tsConfigFile": "ui/tsconfig.json" 56 | } 57 | }, 58 | "testMatch": [ 59 | "**/*.(test|spec).+(ts|tsx|js)" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /repository/README.md: -------------------------------------------------------------------------------- 1 | # リポジトリ層 2 | このパッケージでは、データベースにアクセスするリポジトリ層を実装します。 3 | 4 | `Repository` は、リポジトリ層が提供するインターフェースです。サービス層からタグジャンプするとここにたどり着くので、引数に名前をつけることをおすすめします。 5 | ```go 6 | type Repository interface { 7 | FindUserByID(id uint64) (*User, error) 8 | ListBookmarksByUserID(userID uint64, offset, limit uint64) ([]*Bookmark, error) 9 | } 10 | 11 | func New(dsn string) (Repository, error) { 12 | db, err := sqlx.Open("mysql", dsn) 13 | if err != nil { 14 | return nil, fmt.Errorf("Opening mysql failed: %v", err) 15 | } 16 | return &repository{db: db}, nil 17 | } 18 | ``` 19 | 20 | [database/sqlパッケージのドキュメント](https://golang.org/pkg/database/sql/) や [sqlxパッケージのドキュメント](https://godoc.org/github.com/jmoiron/sqlx)を参考にして、クエリを発行するコードを実装します。 21 | ```go 22 | var userNotFoundError = model.NotFoundError("user") 23 | 24 | func (r *repository) FindUserByID(id uint64) (*User, error) { 25 | var user model.User 26 | err := r.db.Get( 27 | &user, 28 | `SELECT id,name FROM user 29 | WHERE id = ? LIMIT 1`, id, 30 | ) 31 | if err != nil { 32 | if err == sql.ErrNoRows { 33 | return nil, userNotFoundError 34 | } 35 | return nil, err 36 | } 37 | return &user, nil 38 | } 39 | ``` 40 | -------------------------------------------------------------------------------- /repository/bookmark.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | _ "github.com/go-sql-driver/mysql" 8 | "github.com/jmoiron/sqlx" 9 | 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | ) 12 | 13 | var bookmarkNotFoundError = model.NotFoundError("bookmark") 14 | 15 | func (r *repository) FindBookmark(userID uint64, entryID uint64) (*model.Bookmark, error) { 16 | var bookmark model.Bookmark 17 | err := r.db.Get( 18 | &bookmark, 19 | `SELECT id,user_id,entry_id,comment FROM bookmark 20 | WHERE user_id = ? AND entry_id = ? LIMIT 1`, 21 | userID, entryID, 22 | ) 23 | if err != nil { 24 | if err == sql.ErrNoRows { 25 | return nil, bookmarkNotFoundError 26 | } 27 | return nil, err 28 | } 29 | return &bookmark, nil 30 | } 31 | 32 | func (r *repository) FindBookmarkByID(bookmarkID uint64) (*model.Bookmark, error) { 33 | var bookmark model.Bookmark 34 | err := r.db.Get( 35 | &bookmark, 36 | `SELECT id,user_id,entry_id,comment FROM bookmark 37 | WHERE id = ? LIMIT 1`, 38 | bookmarkID, 39 | ) 40 | if err != nil { 41 | if err == sql.ErrNoRows { 42 | return nil, bookmarkNotFoundError 43 | } 44 | return nil, err 45 | } 46 | return &bookmark, nil 47 | } 48 | 49 | func (r *repository) CreateBookmark(userID uint64, entryID uint64, comment string) (*model.Bookmark, error) { 50 | id, err := r.generateID() 51 | if err != nil { 52 | return nil, err 53 | } 54 | now := time.Now() 55 | _, err = r.db.Exec( 56 | `INSERT INTO bookmark 57 | (id, user_id, entry_id, comment, created_at, updated_at) 58 | VALUES (?, ?, ?, ?, ?, ?)`, 59 | id, userID, entryID, comment, now, now, 60 | ) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return &model.Bookmark{ID: id, UserID: userID, EntryID: entryID, Comment: comment}, nil 65 | } 66 | 67 | func (r *repository) UpdateBookmark(id uint64, comment string) error { 68 | now := time.Now() 69 | _, err := r.db.Exec( 70 | `UPDATE bookmark SET comment = ?, updated_at = ? 71 | WHERE id = ?`, 72 | comment, now, id, 73 | ) 74 | return err 75 | } 76 | 77 | func (r *repository) ListBookmarksByIDs(bookmarkIDs []uint64) ([]*model.Bookmark, error) { 78 | if len(bookmarkIDs) == 0 { 79 | return nil, nil 80 | } 81 | bookmarks := make([]*model.Bookmark, 0, len(bookmarkIDs)) 82 | query, args, err := sqlx.In( 83 | `SELECT id,user_id,entry_id,comment FROM bookmark 84 | WHERE id IN (?)`, bookmarkIDs, 85 | ) 86 | if err != nil { 87 | return nil, err 88 | } 89 | err = r.db.Select(&bookmarks, query, args...) 90 | if err != nil { 91 | return nil, err 92 | } 93 | return bookmarks, err 94 | } 95 | 96 | func (r *repository) ListBookmarksByUserID(userID uint64, offset, limit uint64) ([]*model.Bookmark, error) { 97 | bookmarks := make([]*model.Bookmark, 0, limit) 98 | err := r.db.Select( 99 | &bookmarks, 100 | `SELECT id,user_id,entry_id,comment FROM bookmark 101 | WHERE user_id = ? 102 | ORDER BY created_at DESC LIMIT ? OFFSET ?`, 103 | userID, limit, offset, 104 | ) 105 | return bookmarks, err 106 | } 107 | 108 | func (r *repository) ListBookmarksByUserIDs(userIDs []uint64) (map[uint64][]*model.Bookmark, error) { 109 | if len(userIDs) == 0 { 110 | return nil, nil 111 | } 112 | query, args, err := sqlx.In( 113 | `SELECT id,user_id,entry_id,comment FROM bookmark 114 | WHERE user_id IN (?) 115 | ORDER BY created_at DESC`, 116 | userIDs, 117 | ) 118 | if err != nil { 119 | return nil, err 120 | } 121 | rows, err := r.db.Queryx(query, args...) 122 | if err != nil { 123 | return nil, err 124 | } 125 | defer rows.Close() 126 | bookmarks := make(map[uint64][]*model.Bookmark) 127 | for rows.Next() { 128 | var bookmark model.Bookmark 129 | rows.Scan(&bookmark.ID, &bookmark.UserID, &bookmark.EntryID, &bookmark.Comment) 130 | bookmarks[bookmark.UserID] = append(bookmarks[bookmark.UserID], &bookmark) 131 | } 132 | return bookmarks, nil 133 | } 134 | 135 | func (r *repository) ListBookmarksByEntryID(entryID uint64, offset, limit uint64) ([]*model.Bookmark, error) { 136 | bookmarks := make([]*model.Bookmark, 0, limit) 137 | err := r.db.Select( 138 | &bookmarks, 139 | `SELECT id,user_id,entry_id,comment FROM bookmark 140 | WHERE entry_id = ? 141 | ORDER BY updated_at DESC LIMIT ? OFFSET ?`, 142 | entryID, limit, offset, 143 | ) 144 | return bookmarks, err 145 | } 146 | 147 | func (r *repository) ListBookmarksByEntryIDs(entryIDs []uint64) (map[uint64][]*model.Bookmark, error) { 148 | if len(entryIDs) == 0 { 149 | return nil, nil 150 | } 151 | query, args, err := sqlx.In( 152 | `SELECT id,user_id,entry_id,comment FROM bookmark 153 | WHERE entry_id IN (?) 154 | ORDER BY updated_at DESC`, 155 | entryIDs, 156 | ) 157 | if err != nil { 158 | return nil, err 159 | } 160 | rows, err := r.db.Queryx(query, args...) 161 | if err != nil { 162 | return nil, err 163 | } 164 | defer rows.Close() 165 | bookmarks := make(map[uint64][]*model.Bookmark) 166 | for _, entryID := range entryIDs { 167 | bookmarks[entryID] = make([]*model.Bookmark, 0) 168 | } 169 | for rows.Next() { 170 | var bookmark model.Bookmark 171 | rows.Scan(&bookmark.ID, &bookmark.UserID, &bookmark.EntryID, &bookmark.Comment) 172 | bookmarks[bookmark.EntryID] = append(bookmarks[bookmark.EntryID], &bookmark) 173 | } 174 | return bookmarks, nil 175 | } 176 | 177 | func (r *repository) DeleteBookmark(userID uint64, id uint64) error { 178 | _, err := r.db.Exec( 179 | `DELETE FROM bookmark WHERE user_id = ? AND id = ?`, 180 | userID, id, 181 | ) 182 | return err 183 | } 184 | -------------------------------------------------------------------------------- /repository/entry.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | _ "github.com/go-sql-driver/mysql" 8 | "github.com/jmoiron/sqlx" 9 | 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | ) 12 | 13 | var entryNotFoundError = model.NotFoundError("entry") 14 | 15 | func (r *repository) FindEntryByURL(url string) (*model.Entry, error) { 16 | var entry model.Entry 17 | err := r.db.Get( 18 | &entry, 19 | `SELECT id,url,title FROM entry 20 | WHERE url = ? LIMIT 1`, url, 21 | ) 22 | if err != nil { 23 | if err == sql.ErrNoRows { 24 | return nil, entryNotFoundError 25 | } 26 | return nil, err 27 | } 28 | return &entry, nil 29 | } 30 | 31 | func (r *repository) FindEntryByID(id uint64) (*model.Entry, error) { 32 | var entry model.Entry 33 | err := r.db.Get( 34 | &entry, 35 | `SELECT id,url,title FROM entry 36 | WHERE id = ? LIMIT 1`, id, 37 | ) 38 | if err != nil { 39 | if err == sql.ErrNoRows { 40 | return nil, entryNotFoundError 41 | } 42 | return nil, err 43 | } 44 | return &entry, nil 45 | } 46 | 47 | func (r *repository) ListEntriesByIDs(entryIDs []uint64) ([]*model.Entry, error) { 48 | if len(entryIDs) == 0 { 49 | return nil, nil 50 | } 51 | entries := make([]*model.Entry, 0, len(entryIDs)) 52 | query, args, err := sqlx.In( 53 | `SELECT id,url,title FROM entry 54 | WHERE id IN (?)`, entryIDs, 55 | ) 56 | if err != nil { 57 | return nil, err 58 | } 59 | err = r.db.Select(&entries, query, args...) 60 | return entries, err 61 | } 62 | 63 | func (r *repository) CreateEntry(url string, title string) (*model.Entry, error) { 64 | id, err := r.generateID() 65 | if err != nil { 66 | return nil, err 67 | } 68 | now := time.Now() 69 | _, err = r.db.Exec( 70 | `INSERT INTO entry 71 | (id, url, title, created_at, updated_at) 72 | VALUES (?, ?, ?, ?, ?)`, 73 | id, url, title, now, now, 74 | ) 75 | if err != nil { 76 | return nil, err 77 | } 78 | return &model.Entry{ID: id, URL: url, Title: title}, nil 79 | } 80 | 81 | func (r *repository) ListEntries(offset, limit uint64) ([]*model.Entry, error) { 82 | entries := make([]*model.Entry, 0, limit) 83 | err := r.db.Select( 84 | &entries, 85 | `SELECT id,url,title FROM entry 86 | ORDER BY created_at DESC 87 | LIMIT ? OFFSET ?`, 88 | limit, offset, 89 | ) 90 | return entries, err 91 | } 92 | 93 | func (r *repository) BookmarkCountsByEntryIds(entryIDs []uint64) (map[uint64]uint64, error) { 94 | if len(entryIDs) == 0 { 95 | return nil, nil 96 | } 97 | query, args, err := sqlx.In( 98 | `SELECT entry_id,COUNT(1) FROM bookmark 99 | WHERE entry_id IN (?) 100 | GROUP BY entry_id`, entryIDs, 101 | ) 102 | if err != nil { 103 | return nil, err 104 | } 105 | rows, err := r.db.Queryx(query, args...) 106 | if err != nil { 107 | return nil, err 108 | } 109 | defer rows.Close() 110 | bookmarkCounts := make(map[uint64]uint64, len(entryIDs)) 111 | for rows.Next() { 112 | var entryID, count uint64 113 | rows.Scan(&entryID, &count) 114 | bookmarkCounts[entryID] = count 115 | } 116 | return bookmarkCounts, nil 117 | } 118 | -------------------------------------------------------------------------------- /repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | _ "github.com/go-sql-driver/mysql" 8 | "github.com/jmoiron/sqlx" 9 | 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | ) 12 | 13 | type Repository interface { 14 | CreateNewUser(name string, passwordHash string) error 15 | FindUserByName(name string) (*model.User, error) 16 | FindUserByID(id uint64) (*model.User, error) 17 | ListUsersByIDs(userIDs []uint64) ([]*model.User, error) 18 | FindPasswordHashByName(name string) (string, error) 19 | CreateNewToken(userID uint64, token string, expiresAt time.Time) error 20 | FindUserByToken(token string) (*model.User, error) 21 | 22 | FindEntryByURL(url string) (*model.Entry, error) 23 | FindEntryByID(id uint64) (*model.Entry, error) 24 | ListEntriesByIDs(entryIDs []uint64) ([]*model.Entry, error) 25 | CreateEntry(url string, title string) (*model.Entry, error) 26 | ListEntries(offset, limit uint64) ([]*model.Entry, error) 27 | BookmarkCountsByEntryIds(entryIDs []uint64) (map[uint64]uint64, error) 28 | 29 | FindBookmark(userID uint64, entryID uint64) (*model.Bookmark, error) 30 | FindBookmarkByID(bookmarkID uint64) (*model.Bookmark, error) 31 | CreateBookmark(userID uint64, entryID uint64, comment string) (*model.Bookmark, error) 32 | UpdateBookmark(id uint64, comment string) error 33 | ListBookmarksByIDs(bookmarkIDs []uint64) ([]*model.Bookmark, error) 34 | ListBookmarksByUserID(userID uint64, offset, limit uint64) ([]*model.Bookmark, error) 35 | ListBookmarksByUserIDs(userIDs []uint64) (map[uint64][]*model.Bookmark, error) 36 | ListBookmarksByEntryID(entryID uint64, offset, limit uint64) ([]*model.Bookmark, error) 37 | ListBookmarksByEntryIDs(entryIDs []uint64) (map[uint64][]*model.Bookmark, error) 38 | DeleteBookmark(userID uint64, id uint64) error 39 | Close() error 40 | } 41 | 42 | func New(dsn string) (Repository, error) { 43 | db, err := sqlx.Open("mysql", dsn) 44 | if err != nil { 45 | return nil, fmt.Errorf("Opening mysql failed: %v", err) 46 | } 47 | return &repository{db: db}, nil 48 | } 49 | 50 | type repository struct { 51 | db *sqlx.DB 52 | } 53 | 54 | func (r *repository) generateID() (uint64, error) { 55 | var id uint64 56 | err := r.db.Get(&id, "SELECT UUID_SHORT()") 57 | return id, err 58 | } 59 | 60 | func (r *repository) Close() error { 61 | return r.db.Close() 62 | } 63 | -------------------------------------------------------------------------------- /repository/user.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | _ "github.com/go-sql-driver/mysql" 8 | "github.com/jmoiron/sqlx" 9 | 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | ) 12 | 13 | var userNotFoundError = model.NotFoundError("user") 14 | 15 | func (r *repository) CreateNewUser(name string, passwordHash string) error { 16 | id, err := r.generateID() 17 | if err != nil { 18 | return err 19 | } 20 | now := time.Now() 21 | _, err = r.db.Exec( 22 | `INSERT INTO user 23 | (id, name, password_hash, created_at, updated_at) 24 | VALUES (?, ?, ?, ?, ?)`, 25 | id, name, passwordHash, now, now, 26 | ) 27 | return err 28 | } 29 | 30 | func (r *repository) FindUserByName(name string) (*model.User, error) { 31 | var user model.User 32 | err := r.db.Get( 33 | &user, 34 | `SELECT id,name FROM user 35 | WHERE name = ? LIMIT 1`, name, 36 | ) 37 | if err != nil { 38 | if err == sql.ErrNoRows { 39 | return nil, userNotFoundError 40 | } 41 | return nil, err 42 | } 43 | return &user, nil 44 | } 45 | 46 | func (r *repository) FindUserByID(id uint64) (*model.User, error) { 47 | var user model.User 48 | err := r.db.Get( 49 | &user, 50 | `SELECT id,name FROM user 51 | WHERE id = ? LIMIT 1`, id, 52 | ) 53 | if err != nil { 54 | if err == sql.ErrNoRows { 55 | return nil, userNotFoundError 56 | } 57 | return nil, err 58 | } 59 | return &user, nil 60 | } 61 | 62 | func (r *repository) ListUsersByIDs(userIDs []uint64) ([]*model.User, error) { 63 | if len(userIDs) == 0 { 64 | return nil, nil 65 | } 66 | users := make([]*model.User, 0, len(userIDs)) 67 | query, args, err := sqlx.In( 68 | `SELECT id,name FROM user 69 | WHERE id IN (?)`, userIDs, 70 | ) 71 | if err != nil { 72 | return nil, err 73 | } 74 | err = r.db.Select(&users, query, args...) 75 | return users, err 76 | } 77 | 78 | func (r *repository) FindPasswordHashByName(name string) (string, error) { 79 | var hash string 80 | err := r.db.Get( 81 | &hash, 82 | `SELECT password_hash FROM user 83 | WHERE name = ? LIMIT 1`, name, 84 | ) 85 | if err != nil { 86 | if err == sql.ErrNoRows { 87 | return "", nil 88 | } 89 | return "", err 90 | } 91 | return hash, nil 92 | } 93 | 94 | func (r *repository) CreateNewToken(userID uint64, token string, expiresAt time.Time) error { 95 | now := time.Now() 96 | _, err := r.db.Exec( 97 | `INSERT INTO user_session 98 | (user_id, token, expires_at, created_at, updated_at) 99 | VALUES (?, ?, ?, ?, ?)`, 100 | userID, token, expiresAt, now, now, 101 | ) 102 | return err 103 | } 104 | 105 | func (r *repository) FindUserByToken(token string) (*model.User, error) { 106 | var user model.User 107 | err := r.db.Get( 108 | &user, 109 | `SELECT id,name FROM user JOIN user_session 110 | ON user.id = user_session.user_id 111 | WHERE user_session.token = ? && user_session.expires_at > ? 112 | LIMIT 1`, token, time.Now(), 113 | ) 114 | if err != nil { 115 | if err == sql.ErrNoRows { 116 | return nil, userNotFoundError 117 | } 118 | return nil, err 119 | } 120 | return &user, nil 121 | } 122 | -------------------------------------------------------------------------------- /resolver/README.md: -------------------------------------------------------------------------------- 1 | # resolver 2 | このパッケージでは、GraphQLスキーマと、GraphQLのクエリを受けるHTTP handlerを実装します。 3 | 4 | `schema.graphql` にはGraphQLスキーマを書きます。 5 | ```graphql 6 | schema { 7 | query: Query 8 | mutation: Mutation 9 | } 10 | 11 | type Query { 12 | getBookmark(bookmarkId: ID!): Bookmark! 13 | } 14 | 15 | type Mutation { 16 | createBookmark(url: String!, comment: String!): Bookmark! 17 | deleteBookmark(bookmarkId: ID!): Boolean! 18 | } 19 | 20 | type Bookmark { 21 | // ... 22 | } 23 | ``` 24 | 25 | `handler.go` では、このスキーマをloadし (実際にはgo-assets-builderによって実行ファイルに埋め込まれる)、 `http.Handler` を返す実装を行います。 26 | ```go 27 | func NewHandler(app service.BookmarkApp) http.Handler { 28 | graphqlSchema, err := loadGraphQLSchema() 29 | if err != nil { 30 | panic(err) 31 | } 32 | schema := graphql.MustParseSchema(string(graphqlSchema), newResolver(app)) 33 | return &relay.Handler{Schema: schema} 34 | } 35 | ``` 36 | 37 | `Resolver` は、GraphQLクエリの引数を受け取り、データを引いてきて返します。 38 | ```go 39 | // getBookmark(bookmarkId: ID!): Bookmark! に対応する 40 | 41 | func (r *resolver) GetBookmark(ctx context.Context, args struct{ BookmarkID string }) (*bookmarkResolver, error) { 42 | // ... 43 | return &bookmarkResolver{bookmark: bookmark}, nil 44 | } 45 | ``` 46 | 47 | 各モデルのプロパティーをたどってデータを取得できるように、スキーマに対応する名前の公開関数を実装します。 48 | ```go 49 | // type Bookmark { 50 | // id: ID! 51 | // comment: String! 52 | // user: User! 53 | // entry: Entry! 54 | // } 55 | 56 | func (b *bookmarkResolver) ID(ctx context.Context) graphql.ID { 57 | return graphql.ID(fmt.Sprint(b.bookmark.ID)) 58 | } 59 | 60 | func (b *bookmarkResolver) Comment(ctx context.Context) string { 61 | return b.bookmark.Comment 62 | } 63 | 64 | func (b *bookmarkResolver) User(ctx context.Context) (*userResolver, error) { 65 | // ... 66 | return &userResolver{user: user}, nil 67 | } 68 | 69 | func (b *bookmarkResolver) Entry(ctx context.Context) (*entryResolver, error) { 70 | // ... 71 | return &entryResolver{entry: entry}, nil 72 | } 73 | ``` 74 | 75 | `User` と `Entry` を返すには、更にデータベースを引かなければいけません。 76 | `resolver` が `app` を持っているので、 `bookmarkResolver` にも `app` を持たせて直接引いても構いません。 77 | なぜ `Context` を引数にとり、 `loader` パッケージを介してデータを取得しているのでしょうか。 78 | -------------------------------------------------------------------------------- /resolver/bookmark_resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/graph-gophers/graphql-go" 8 | 9 | "github.com/hatena/go-Intern-Bookmark/loader" 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | ) 12 | 13 | type bookmarkResolver struct { 14 | bookmark *model.Bookmark 15 | } 16 | 17 | func (b *bookmarkResolver) ID(ctx context.Context) graphql.ID { 18 | return graphql.ID(fmt.Sprint(b.bookmark.ID)) 19 | } 20 | 21 | func (b *bookmarkResolver) Comment(ctx context.Context) string { 22 | return b.bookmark.Comment 23 | } 24 | 25 | func (b *bookmarkResolver) User(ctx context.Context) (*userResolver, error) { 26 | user, err := loader.LoadUser(ctx, b.bookmark.UserID) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return &userResolver{user: user}, nil 31 | } 32 | 33 | func (b *bookmarkResolver) Entry(ctx context.Context) (*entryResolver, error) { 34 | entry, err := loader.LoadEntry(ctx, b.bookmark.EntryID) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return &entryResolver{entry: entry}, nil 39 | } 40 | -------------------------------------------------------------------------------- /resolver/entry_resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/graph-gophers/graphql-go" 8 | 9 | "github.com/hatena/go-Intern-Bookmark/loader" 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | ) 12 | 13 | type entryResolver struct { 14 | entry *model.Entry 15 | } 16 | 17 | func (e *entryResolver) ID(ctx context.Context) graphql.ID { 18 | return graphql.ID(fmt.Sprint(e.entry.ID)) 19 | } 20 | 21 | func (e *entryResolver) URL(ctx context.Context) string { 22 | return e.entry.URL 23 | } 24 | 25 | func (e *entryResolver) Title(ctx context.Context) string { 26 | return e.entry.Title 27 | } 28 | 29 | func (e *entryResolver) Bookmarks(ctx context.Context) ([]*bookmarkResolver, error) { 30 | bookmarks, err := loader.LoadBookmarksByEntryID(ctx, e.entry.ID) 31 | if err != nil { 32 | return nil, err 33 | } 34 | brs := make([]*bookmarkResolver, len(bookmarks)) 35 | for i, bookmark := range bookmarks { 36 | brs[i] = &bookmarkResolver{bookmark: bookmark} 37 | } 38 | return brs, nil 39 | } 40 | -------------------------------------------------------------------------------- /resolver/handler.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | 7 | "github.com/graph-gophers/graphql-go" 8 | "github.com/graph-gophers/graphql-go/relay" 9 | 10 | "github.com/hatena/go-Intern-Bookmark/service" 11 | ) 12 | 13 | //go:generate go-assets-builder --package=resolver --output=./schema-gen.go --strip-prefix="/" --variable=Schema schema.graphql 14 | 15 | func loadGraphQLSchema() ([]byte, error) { 16 | file, err := Schema.Open("schema.graphql") 17 | if err != nil { 18 | return nil, err 19 | } 20 | schemaBytes, err := ioutil.ReadAll(file) 21 | if err != nil { 22 | return nil, nil 23 | } 24 | return schemaBytes, nil 25 | } 26 | 27 | func NewHandler(app service.BookmarkApp) http.Handler { 28 | graphqlSchema, err := loadGraphQLSchema() 29 | if err != nil { 30 | panic(err) 31 | } 32 | schema := graphql.MustParseSchema(string(graphqlSchema), newResolver(app)) 33 | return &relay.Handler{Schema: schema} 34 | } 35 | -------------------------------------------------------------------------------- /resolver/resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "strconv" 7 | 8 | "github.com/hatena/go-Intern-Bookmark/model" 9 | "github.com/hatena/go-Intern-Bookmark/service" 10 | ) 11 | 12 | type Resolver interface { 13 | Visitor(context.Context) (*userResolver, error) 14 | GetUser(context.Context, struct{ UserID string }) (*userResolver, error) 15 | 16 | CreateBookmark(context.Context, struct{ URL, Comment string }) (*bookmarkResolver, error) 17 | GetBookmark(context.Context, struct{ BookmarkID string }) (*bookmarkResolver, error) 18 | DeleteBookmark(context.Context, struct{ BookmarkID string }) (bool, error) 19 | 20 | GetEntry(context.Context, struct{ EntryID string }) (*entryResolver, error) 21 | ListEntries(context.Context) ([]*entryResolver, error) 22 | } 23 | 24 | func newResolver(app service.BookmarkApp) Resolver { 25 | return &resolver{app: app} 26 | } 27 | 28 | type resolver struct { 29 | app service.BookmarkApp 30 | } 31 | 32 | func currentUser(ctx context.Context) *model.User { 33 | return ctx.Value("user").(*model.User) 34 | } 35 | 36 | func (r *resolver) Visitor(ctx context.Context) (*userResolver, error) { 37 | return &userResolver{currentUser(ctx)}, nil 38 | } 39 | 40 | func (r *resolver) GetUser(ctx context.Context, args struct{ UserID string }) (*userResolver, error) { 41 | userID, err := strconv.ParseUint(args.UserID, 10, 64) 42 | if err != nil { 43 | return nil, err 44 | } 45 | user, err := r.app.FindUserByID(userID) 46 | if err != nil { 47 | return nil, err 48 | } 49 | if user == nil { 50 | return nil, errors.New("user not found") 51 | } 52 | return &userResolver{user}, nil 53 | } 54 | 55 | func (r *resolver) CreateBookmark(ctx context.Context, args struct{ URL, Comment string }) (*bookmarkResolver, error) { 56 | user := currentUser(ctx) 57 | if user == nil { 58 | return nil, errors.New("user not found") 59 | } 60 | bookmark, err := r.app.CreateOrUpdateBookmark(user.ID, args.URL, args.Comment) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return &bookmarkResolver{bookmark: bookmark}, nil 65 | } 66 | 67 | func (r *resolver) GetBookmark(ctx context.Context, args struct{ BookmarkID string }) (*bookmarkResolver, error) { 68 | bookmarkID, err := strconv.ParseUint(args.BookmarkID, 10, 64) 69 | if err != nil { 70 | return nil, err 71 | } 72 | bookmark, err := r.app.FindBookmarkByID(bookmarkID) 73 | if err != nil { 74 | return nil, err 75 | } 76 | if bookmark == nil { 77 | return nil, errors.New("bookmark not found") 78 | } 79 | return &bookmarkResolver{bookmark: bookmark}, nil 80 | } 81 | 82 | func (r *resolver) DeleteBookmark(ctx context.Context, args struct{ BookmarkID string }) (bool, error) { 83 | user := currentUser(ctx) 84 | if user == nil { 85 | return false, errors.New("user not found") 86 | } 87 | bookmarkID, err := strconv.ParseUint(args.BookmarkID, 10, 64) 88 | if err != nil { 89 | return false, err 90 | } 91 | err = r.app.DeleteBookmark(user.ID, bookmarkID) 92 | if err != nil { 93 | return false, err 94 | } 95 | return true, nil 96 | } 97 | 98 | func (r *resolver) GetEntry(ctx context.Context, args struct{ EntryID string }) (*entryResolver, error) { 99 | entryID, err := strconv.ParseUint(args.EntryID, 10, 64) 100 | if err != nil { 101 | return nil, err 102 | } 103 | entry, err := r.app.FindEntryByID(entryID) 104 | if err != nil { 105 | return nil, err 106 | } 107 | if entry == nil { 108 | return nil, errors.New("entry not found") 109 | } 110 | return &entryResolver{entry: entry}, nil 111 | } 112 | 113 | func (r *resolver) ListEntries(ctx context.Context) ([]*entryResolver, error) { 114 | entries, err := r.app.ListEntries(1, 10) 115 | if err != nil { 116 | return nil, err 117 | } 118 | ers := make([]*entryResolver, len(entries)) 119 | for i, entry := range entries { 120 | ers[i] = &entryResolver{entry: entry} 121 | } 122 | return ers, nil 123 | } 124 | -------------------------------------------------------------------------------- /resolver/schema.graphql: -------------------------------------------------------------------------------- 1 | schema { 2 | query: Query 3 | mutation: Mutation 4 | } 5 | 6 | type Query { 7 | visitor(): User! 8 | getUser(userId: ID!): User! 9 | getBookmark(bookmarkId: ID!): Bookmark! 10 | getEntry(entryId: ID!): Entry! 11 | listEntries(): [Entry!]! 12 | } 13 | 14 | type Mutation { 15 | createBookmark(url: String!, comment: String!): Bookmark! 16 | deleteBookmark(bookmarkId: ID!): Boolean! 17 | } 18 | 19 | type User { 20 | id: ID! 21 | name: String! 22 | bookmarks: [Bookmark!]! 23 | } 24 | 25 | type Entry { 26 | id: ID! 27 | url: String! 28 | title: String! 29 | bookmarks: [Bookmark!]! 30 | } 31 | 32 | type Bookmark { 33 | id: ID! 34 | comment: String! 35 | user: User! 36 | entry: Entry! 37 | } 38 | -------------------------------------------------------------------------------- /resolver/user_resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/graph-gophers/graphql-go" 8 | 9 | "github.com/hatena/go-Intern-Bookmark/loader" 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | ) 12 | 13 | type userResolver struct { 14 | user *model.User 15 | } 16 | 17 | func (u *userResolver) ID(ctx context.Context) graphql.ID { 18 | return graphql.ID(fmt.Sprint(u.user.ID)) 19 | } 20 | 21 | func (u *userResolver) Name(ctx context.Context) string { 22 | return u.user.Name 23 | } 24 | 25 | func (u *userResolver) Bookmarks(ctx context.Context) ([]*bookmarkResolver, error) { 26 | bookmarks, err := loader.LoadBookmarksByUserID(ctx, u.user.ID) 27 | if err != nil { 28 | return nil, err 29 | } 30 | bs := make([]*bookmarkResolver, len(bookmarks)) 31 | for i, bookmark := range bookmarks { 32 | bs[i] = &bookmarkResolver{bookmark: bookmark} 33 | } 34 | return bs, nil 35 | } 36 | -------------------------------------------------------------------------------- /script/localup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | reflex -r '\.(go|tmpl)$' -R '^vendor' -R '-gen.go$' -R '^node_modules' -s -d none -- \ 4 | sh -c 'make run' 5 | -------------------------------------------------------------------------------- /service/README.md: -------------------------------------------------------------------------------- 1 | # サービス層 2 | サービス層が公開している `BookmarkApp` は、アプリケーションロジックを表現するinterfaceです。 3 | ```go 4 | type BookmarkApp interface { 5 | FindUserByID(userID uint64) (*model.User, error) 6 | CreateOrUpdateBookmark(userID uint64, url string, comment string) (*model.Bookmark, error) 7 | DeleteBookmark(userID uint64, bookmarkID uint64) error 8 | } 9 | ``` 10 | 11 | リポジトリを保持する `bookmarkApp` 構造体に対して実装を行います。 12 | ```go 13 | type bookmarkApp struct { 14 | repo repository.Repository 15 | } 16 | 17 | func (app *bookmarkApp) FindUserByID(userID uint64) (*model.User, error) { 18 | return app.repo.FindUserByID(userID) 19 | } 20 | 21 | func (app *bookmarkApp) DeleteBookmark(userID uint64, bookmarkID uint64) error { 22 | return app.repo.DeleteBookmark(userID, bookmarkID) 23 | } 24 | ``` 25 | -------------------------------------------------------------------------------- /service/bookmark.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/hatena/go-Intern-Bookmark/model" 7 | ) 8 | 9 | func (app *bookmarkApp) CreateOrUpdateBookmark(userID uint64, url string, comment string) (*model.Bookmark, error) { 10 | if url == "" { 11 | return nil, errors.New("empty url") 12 | } 13 | user, err := app.repo.FindUserByID(userID) 14 | if err != nil { 15 | return nil, err 16 | } 17 | entry, err := app.FindOrCreateEntry(url) 18 | if err != nil { 19 | return nil, err 20 | } 21 | bookmark, err := app.repo.FindBookmark(user.ID, entry.ID) 22 | if bookmark != nil { 23 | if err := app.repo.UpdateBookmark(bookmark.ID, comment); err != nil { 24 | return nil, err 25 | } 26 | return app.repo.FindBookmark(user.ID, entry.ID) 27 | } 28 | return app.repo.CreateBookmark(user.ID, entry.ID, comment) 29 | } 30 | 31 | func (app *bookmarkApp) FindBookmarkByID(bookmarkID uint64) (*model.Bookmark, error) { 32 | return app.repo.FindBookmarkByID(bookmarkID) 33 | } 34 | 35 | func (app *bookmarkApp) ListBookmarksByIDs(bookmarkIDs []uint64) ([]*model.Bookmark, error) { 36 | return app.repo.ListBookmarksByIDs(bookmarkIDs) 37 | } 38 | 39 | func (app *bookmarkApp) ListBookmarksByUserID(userID uint64, page uint64, limit uint64) ([]*model.Bookmark, error) { 40 | if page < 1 || limit < 1 { 41 | return nil, errors.New("page and limit should be positive") 42 | } 43 | return app.repo.ListBookmarksByUserID(userID, (page-1)*limit, limit) 44 | } 45 | 46 | func (app *bookmarkApp) ListBookmarksByUserIDs(userIDs []uint64) (map[uint64][]*model.Bookmark, error) { 47 | return app.repo.ListBookmarksByUserIDs(userIDs) 48 | } 49 | 50 | func (app *bookmarkApp) ListBookmarksByEntryID(entryID uint64, page uint64, limit uint64) ([]*model.Bookmark, error) { 51 | if page < 1 || limit < 1 { 52 | return nil, errors.New("page and limit should be positive") 53 | } 54 | return app.repo.ListBookmarksByEntryID(entryID, (page-1)*limit, limit) 55 | } 56 | 57 | func (app *bookmarkApp) ListBookmarksByEntryIDs(entryIDs []uint64) (map[uint64][]*model.Bookmark, error) { 58 | return app.repo.ListBookmarksByEntryIDs(entryIDs) 59 | } 60 | 61 | func (app *bookmarkApp) DeleteBookmark(userID uint64, bookmarkID uint64) error { 62 | return app.repo.DeleteBookmark(userID, bookmarkID) 63 | } 64 | -------------------------------------------------------------------------------- /service/bookmark_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | 8 | "github.com/hatena/go-Intern-Bookmark/model" 9 | ) 10 | 11 | func createTestUser(app BookmarkApp) *model.User { 12 | name := "test name " + randomString() 13 | password := randomString() + randomString() 14 | err := app.CreateNewUser(name, password) 15 | if err != nil { 16 | panic(err) 17 | } 18 | user, err := app.FindUserByName(name) 19 | if err != nil { 20 | panic(err) 21 | } 22 | return user 23 | } 24 | 25 | func TestBookmarkApp_CreateOrUpdateBookmark(t *testing.T) { 26 | app := newApp() 27 | defer closeApp(app) 28 | 29 | user := createTestUser(app) 30 | 31 | url := "https://example.com/" + randomString() 32 | comment := "ブックマークコメント " + randomString() 33 | bookmark, err := app.CreateOrUpdateBookmark(user.ID, url, comment) 34 | assert.NoError(t, err) 35 | assert.Equal(t, user.ID, bookmark.UserID) 36 | assert.Equal(t, comment, bookmark.Comment) 37 | 38 | comment = "新しい ブックマークコメント " + randomString() 39 | bookmark, err = app.CreateOrUpdateBookmark(user.ID, url, comment) 40 | assert.NoError(t, err) 41 | assert.Equal(t, user.ID, bookmark.UserID) 42 | assert.Equal(t, comment, bookmark.Comment) 43 | 44 | b, err := app.FindBookmarkByID(bookmark.ID) 45 | assert.NoError(t, err) 46 | assert.Equal(t, user.ID, b.UserID) 47 | assert.Equal(t, comment, b.Comment) 48 | } 49 | 50 | func TestBookmarkApp_ListBookmarksByUserID(t *testing.T) { 51 | app := newApp() 52 | defer closeApp(app) 53 | 54 | user := createTestUser(app) 55 | 56 | url := "https://example.com/" + randomString() 57 | comment := "ブックマークコメント " + randomString() 58 | bookmark, err := app.CreateOrUpdateBookmark(user.ID, url, comment) 59 | 60 | bookmarks, err := app.ListBookmarksByUserID(user.ID, 1, 10) 61 | assert.NoError(t, err) 62 | assert.Len(t, bookmarks, 1) 63 | 64 | err = app.DeleteBookmark(user.ID, bookmark.ID) 65 | assert.NoError(t, err) 66 | 67 | bookmarks, err = app.ListBookmarksByUserID(user.ID, 1, 10) 68 | assert.NoError(t, err) 69 | assert.Len(t, bookmarks, 0) 70 | } 71 | -------------------------------------------------------------------------------- /service/entry.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/hatena/go-Intern-Bookmark/model" 7 | ) 8 | 9 | func (app *bookmarkApp) FindOrCreateEntry(url string) (*model.Entry, error) { 10 | entry, err := app.repo.FindEntryByURL(url) 11 | if err != nil { 12 | if model.IsNotFound(err) { 13 | title, err := app.titleFetcher.Fetch(url) 14 | if err != nil { 15 | title = url 16 | } 17 | return app.repo.CreateEntry(url, title) 18 | } 19 | return nil, err 20 | } 21 | return entry, err 22 | } 23 | 24 | func (app *bookmarkApp) ListEntries(page uint64, limit uint64) ([]*model.Entry, error) { 25 | if page < 1 || limit < 1 { 26 | return nil, errors.New("page and limit should be positive") 27 | } 28 | return app.repo.ListEntries((page-1)*limit, limit) 29 | } 30 | 31 | func (app *bookmarkApp) FindEntryByID(entryID uint64) (*model.Entry, error) { 32 | return app.repo.FindEntryByID(entryID) 33 | } 34 | 35 | func (app *bookmarkApp) ListEntriesByIDs(entryIDs []uint64) ([]*model.Entry, error) { 36 | return app.repo.ListEntriesByIDs(entryIDs) 37 | } 38 | 39 | func (app *bookmarkApp) BookmarkCountsByEntryIds(entryIDs []uint64) (map[uint64]uint64, error) { 40 | return app.repo.BookmarkCountsByEntryIds(entryIDs) 41 | } 42 | -------------------------------------------------------------------------------- /service/entry_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/hatena/go-Intern-Bookmark/model" 10 | ) 11 | 12 | func TestBookmarkApp_FindOrCreateEntry(t *testing.T) { 13 | app := newApp() 14 | defer closeApp(app) 15 | 16 | url := "https://example.com/" + randomString() 17 | entry, err := app.FindOrCreateEntry(url) 18 | assert.NoError(t, err) 19 | assert.Equal(t, url, entry.URL) 20 | assert.Equal(t, "Example Domain", entry.Title) 21 | 22 | entry, err = app.FindOrCreateEntry(url) 23 | assert.NoError(t, err) 24 | assert.Equal(t, url, entry.URL) 25 | assert.Equal(t, "Example Domain", entry.Title) 26 | 27 | url = "http://b.hatena.ne.jp" 28 | entry, err = app.FindOrCreateEntry(url) 29 | assert.NoError(t, err) 30 | assert.Equal(t, url, entry.URL) 31 | assert.Equal(t, "はてなブックマーク", entry.Title) 32 | } 33 | 34 | func TestBookmarkApp_FindEntryByID(t *testing.T) { 35 | app := newApp() 36 | defer closeApp(app) 37 | 38 | url := "https://example.com/" + randomString() 39 | entry, err := app.FindOrCreateEntry(url) 40 | assert.NoError(t, err) 41 | assert.Equal(t, url, entry.URL) 42 | assert.Equal(t, "Example Domain", entry.Title) 43 | 44 | e, err := app.FindEntryByID(entry.ID) 45 | assert.NoError(t, err) 46 | assert.Equal(t, e.URL, entry.URL) 47 | assert.Equal(t, e.Title, entry.Title) 48 | } 49 | 50 | func TestBookmarkApp_ListEntries(t *testing.T) { 51 | app := newApp() 52 | defer closeApp(app) 53 | 54 | url := "https://example.com/" + randomString() 55 | 56 | for i := 0; i < 10; i++ { 57 | _, err := app.FindOrCreateEntry(url + fmt.Sprint(i)) 58 | assert.NoError(t, err) 59 | } 60 | 61 | entries, err := app.ListEntries(1, 3) 62 | assert.NoError(t, err) 63 | assert.Len(t, entries, 3) 64 | assert.Equal(t, url+"9", entries[0].URL) 65 | assert.Equal(t, url+"8", entries[1].URL) 66 | assert.Equal(t, url+"7", entries[2].URL) 67 | 68 | entries, err = app.ListEntries(2, 4) 69 | assert.NoError(t, err) 70 | assert.Len(t, entries, 4) 71 | assert.Equal(t, url+"5", entries[0].URL) 72 | assert.Equal(t, url+"4", entries[1].URL) 73 | } 74 | 75 | func TestBookmarkApp_ListEntriesByIDs(t *testing.T) { 76 | app := newApp() 77 | defer closeApp(app) 78 | 79 | url := "https://example.com/" 80 | entry1, err := app.FindOrCreateEntry(url) 81 | assert.NoError(t, err) 82 | 83 | url = "http://b.hatena.ne.jp" 84 | entry2, err := app.FindOrCreateEntry(url) 85 | assert.NoError(t, err) 86 | 87 | url = "http://hatena.ne.jp" 88 | entry3, err := app.FindOrCreateEntry(url) 89 | assert.NoError(t, err) 90 | 91 | entries, err := app.ListEntriesByIDs([]uint64{entry1.ID, entry2.ID, entry3.ID}) 92 | assert.NoError(t, err) 93 | assert.Len(t, entries, 3) 94 | for _, entry := range []*model.Entry{entry1, entry2, entry3} { 95 | var contained bool 96 | for _, e := range entries { 97 | contained = contained || entry.ID == e.ID 98 | } 99 | assert.True(t, contained) 100 | } 101 | 102 | entries, err = app.ListEntriesByIDs([]uint64{}) 103 | assert.NoError(t, err) 104 | assert.Len(t, entries, 0) 105 | } 106 | -------------------------------------------------------------------------------- /service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | 7 | "github.com/hatena/go-Intern-Bookmark/model" 8 | "github.com/hatena/go-Intern-Bookmark/repository" 9 | "github.com/hatena/go-Intern-Bookmark/titleFetcher" 10 | ) 11 | 12 | func init() { 13 | rand.Seed(time.Now().UnixNano()) 14 | } 15 | 16 | type BookmarkApp interface { 17 | Close() error 18 | 19 | CreateNewUser(name string, passwordHash string) error 20 | FindUserByName(name string) (*model.User, error) 21 | FindUserByID(userID uint64) (*model.User, error) 22 | ListUsersByIDs(userIDs []uint64) ([]*model.User, error) 23 | LoginUser(name string, password string) (bool, error) 24 | CreateNewToken(userID uint64, expiresAt time.Time) (string, error) 25 | FindUserByToken(token string) (*model.User, error) 26 | 27 | FindOrCreateEntry(url string) (*model.Entry, error) 28 | ListEntries(page uint64, limit uint64) ([]*model.Entry, error) 29 | FindEntryByID(entryID uint64) (*model.Entry, error) 30 | ListEntriesByIDs(entryIDs []uint64) ([]*model.Entry, error) 31 | BookmarkCountsByEntryIds(entryIDs []uint64) (map[uint64]uint64, error) 32 | 33 | CreateOrUpdateBookmark(userID uint64, url string, comment string) (*model.Bookmark, error) 34 | FindBookmarkByID(bookmarkIDs uint64) (*model.Bookmark, error) 35 | ListBookmarksByIDs(bookmarkIDs []uint64) ([]*model.Bookmark, error) 36 | ListBookmarksByUserID(userID uint64, page uint64, limit uint64) ([]*model.Bookmark, error) 37 | ListBookmarksByUserIDs(userIDs []uint64) (map[uint64][]*model.Bookmark, error) 38 | ListBookmarksByEntryID(entryID uint64, page uint64, limit uint64) ([]*model.Bookmark, error) 39 | ListBookmarksByEntryIDs(entryIDs []uint64) (map[uint64][]*model.Bookmark, error) 40 | DeleteBookmark(userID uint64, bookmarkID uint64) error 41 | } 42 | 43 | func NewApp(repo repository.Repository, titleFetcher titleFetcher.TitleFetcher) BookmarkApp { 44 | return &bookmarkApp{repo, titleFetcher} 45 | } 46 | 47 | type bookmarkApp struct { 48 | repo repository.Repository 49 | titleFetcher titleFetcher.TitleFetcher 50 | } 51 | 52 | func (app *bookmarkApp) Close() error { 53 | return app.repo.Close() 54 | } 55 | -------------------------------------------------------------------------------- /service/service_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "math/rand" 5 | "strconv" 6 | "time" 7 | 8 | "github.com/hatena/go-Intern-Bookmark/config" 9 | "github.com/hatena/go-Intern-Bookmark/repository" 10 | "github.com/hatena/go-Intern-Bookmark/titleFetcher" 11 | ) 12 | 13 | func newApp() BookmarkApp { 14 | conf, err := config.Load() 15 | if err != nil { 16 | panic(err) 17 | } 18 | repo, err := repository.New(conf.DbDsn) 19 | if err != nil { 20 | panic(err) 21 | } 22 | return NewApp(repo, titleFetcher.New()) 23 | } 24 | 25 | func closeApp(app BookmarkApp) { 26 | err := app.Close() 27 | if err != nil { 28 | panic(err) 29 | } 30 | } 31 | 32 | func randomString() string { 33 | return strconv.FormatInt(time.Now().Unix()^rand.Int63(), 16) 34 | } 35 | -------------------------------------------------------------------------------- /service/user.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "errors" 5 | "math/rand" 6 | "time" 7 | 8 | "golang.org/x/crypto/bcrypt" 9 | 10 | "github.com/hatena/go-Intern-Bookmark/model" 11 | ) 12 | 13 | func (app *bookmarkApp) CreateNewUser(name string, password string) (err error) { 14 | if name == "" { 15 | return errors.New("empty user name") 16 | } 17 | passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 18 | if err != nil { 19 | return err 20 | } 21 | return app.repo.CreateNewUser(name, string(passwordHash)) 22 | } 23 | 24 | func (app *bookmarkApp) FindUserByName(name string) (*model.User, error) { 25 | return app.repo.FindUserByName(name) 26 | } 27 | 28 | func (app *bookmarkApp) FindUserByID(userID uint64) (*model.User, error) { 29 | return app.repo.FindUserByID(userID) 30 | } 31 | 32 | func (app *bookmarkApp) ListUsersByIDs(userIDs []uint64) ([]*model.User, error) { 33 | return app.repo.ListUsersByIDs(userIDs) 34 | } 35 | 36 | func (app *bookmarkApp) LoginUser(name string, password string) (bool, error) { 37 | passwordHash, err := app.repo.FindPasswordHashByName(name) 38 | if err != nil { 39 | return false, err 40 | } 41 | if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil { 42 | if err == bcrypt.ErrMismatchedHashAndPassword { 43 | return false, nil 44 | } 45 | return false, err 46 | } 47 | return true, nil 48 | } 49 | 50 | func generateToken() string { 51 | table := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_@" 52 | l := len(table) 53 | ret := make([]byte, 128) 54 | src := make([]byte, 128) 55 | rand.Read(src) 56 | for i := 0; i < 128; i++ { 57 | ret[i] = table[int(src[i])%l] 58 | } 59 | return string(ret) 60 | } 61 | 62 | func (app *bookmarkApp) CreateNewToken(userID uint64, expiresAt time.Time) (string, error) { 63 | token := generateToken() 64 | err := app.repo.CreateNewToken(userID, token, expiresAt) 65 | if err != nil { 66 | return "", err 67 | } 68 | return token, nil 69 | } 70 | 71 | func (app *bookmarkApp) FindUserByToken(token string) (*model.User, error) { 72 | return app.repo.FindUserByToken(token) 73 | } 74 | -------------------------------------------------------------------------------- /service/user_test.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestBookmarkApp_FindUserByName(t *testing.T) { 11 | app := newApp() 12 | defer closeApp(app) 13 | 14 | name := "test name " + randomString() 15 | password := randomString() + randomString() 16 | err := app.CreateNewUser(name, password) 17 | assert.NoError(t, err) 18 | 19 | user, err := app.FindUserByName(name) 20 | assert.NoError(t, err) 21 | assert.Equal(t, user.Name, name) 22 | 23 | user2, err := app.FindUserByID(user.ID) 24 | assert.NoError(t, err) 25 | assert.Equal(t, user2.Name, name) 26 | } 27 | 28 | func TestBookmarkApp_ListUsersByIDs(t *testing.T) { 29 | app := newApp() 30 | defer closeApp(app) 31 | 32 | userIDs := make([]uint64, 5) 33 | for i := 0; i < 5; i++ { 34 | name := "test name " + randomString() 35 | password := randomString() + randomString() 36 | _ = app.CreateNewUser(name, password) 37 | user, _ := app.FindUserByName(name) 38 | userIDs[i] = user.ID 39 | } 40 | 41 | users, err := app.ListUsersByIDs(userIDs) 42 | assert.NoError(t, err) 43 | assert.Len(t, users, 5) 44 | } 45 | 46 | func TestBookmarkApp_LoginUser(t *testing.T) { 47 | app := newApp() 48 | defer closeApp(app) 49 | 50 | name := "test name " + randomString() 51 | password := randomString() + randomString() 52 | err := app.CreateNewUser(name, password) 53 | assert.NoError(t, err) 54 | 55 | login, err := app.LoginUser(name, password) 56 | assert.NoError(t, err) 57 | assert.True(t, login) 58 | 59 | login, err = app.LoginUser(name, password+".") 60 | assert.NoError(t, err) 61 | assert.False(t, login) 62 | } 63 | 64 | func TestBookmarkApp_CreateNewToken(t *testing.T) { 65 | app := newApp() 66 | defer closeApp(app) 67 | 68 | name := "test name " + randomString() 69 | password := randomString() + randomString() 70 | err := app.CreateNewUser(name, password) 71 | assert.NoError(t, err) 72 | user, _ := app.FindUserByName(name) 73 | 74 | token, err := app.CreateNewToken(user.ID, time.Now().Add(1*time.Hour)) 75 | assert.NoError(t, err) 76 | assert.NotEqual(t, "", token) 77 | 78 | u, err := app.FindUserByToken(token) 79 | assert.NoError(t, err) 80 | assert.Equal(t, user.ID, u.ID) 81 | } 82 | -------------------------------------------------------------------------------- /templates/README.md: -------------------------------------------------------------------------------- 1 | # テンプレート 2 | HTMLのテンプレートをこのディレクトリーに置きます。 3 | 4 | 参考: https://golang.org/pkg/html/template/ 5 | -------------------------------------------------------------------------------- /templates/add.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}ブックマーク追加{{end}} 2 | 3 | {{define "body"}} 4 |

ブックマーク追加

5 | 6 | ユーザー名: {{.User.Name}} 7 |
8 | 9 | 10 |
11 | 12 |
13 | 14 |
URL:
15 |
コメント:
16 | 17 |
18 | 19 | {{end}} 20 | -------------------------------------------------------------------------------- /templates/bookmarks.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}マイブックマーク{{end}} 2 | 3 | {{define "body"}} 4 |

{{.User.Name}}のマイブックマーク

5 | 6 | ユーザー名: {{.User.Name}} 7 |
8 | 9 | 10 |
11 | 12 |
13 | ブックマークを追加 14 |
15 | 16 | {{range .Bookmarks}} 17 |
18 | {{.Entry.Title}} 19 | {{.Entry.URL}} 20 | {{.Bookmark.Comment}} 21 |
22 | 23 | 24 |
25 |
26 | {{end}} 27 | 28 | {{end}} 29 | -------------------------------------------------------------------------------- /templates/entry.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}{{.Entry.Title}}のブックマーク{{end}} 2 | 3 | {{define "body"}} 4 |

{{.Entry.Title}}のブックマーク

5 | 6 | {{if .User}} 7 | ユーザー名: {{.User.Name}} 8 |
9 | 10 | 11 |
12 | {{else}} 13 | ユーザー登録 14 | ログイン 15 | {{end}} 16 | 17 | {{range .Bookmarks}} 18 |
19 | {{.User.Name}}: {{.Bookmark.Comment}} 20 |
21 | {{end}} 22 | 23 | {{end}} 24 | -------------------------------------------------------------------------------- /templates/graphiql.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
Loading...
12 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /templates/index.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}ブックマーク{{end}} 2 | 3 | {{define "body"}} 4 |

ブックマーク

5 | 6 | {{if .User}} 7 | ユーザー名: {{.User.Name}} 8 |
9 | 10 | 11 |
12 | {{else}} 13 | ユーザー登録 14 | ログイン 15 | {{end}} 16 | 17 | {{if .User}} 18 |
19 | ブックマークを追加 20 |
21 | 22 |
23 | マイブックマーク 24 |
25 | {{end}} 26 | 27 | {{range .Entries}} 28 | {{if gt .Count 0}} 29 |
30 | {{.Count}} USERS 31 | {{.Title}} 32 | {{.URL}} 33 |
34 | {{end}} 35 | {{end}} 36 | 37 | {{end}} 38 | -------------------------------------------------------------------------------- /templates/main.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{block "title" .}}{{end}} 6 | 7 | 8 | 9 | 10 | {{block "body" .}}{{end}} 11 | 12 | 13 | -------------------------------------------------------------------------------- /templates/signin.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}ログイン{{end}} 2 | 3 | {{define "body"}} 4 |

ログイン

5 | 6 |
7 | 8 |
名前:
9 |
パスワード:
10 | 11 |
12 | {{end}} 13 | -------------------------------------------------------------------------------- /templates/signup.tmpl: -------------------------------------------------------------------------------- 1 | {{define "title"}}ユーザー登録{{end}} 2 | 3 | {{define "body"}} 4 |

ユーザー登録

5 | 6 |
7 | 8 |
名前:
9 |
パスワード:
10 | 11 |
12 | {{end}} 13 | -------------------------------------------------------------------------------- /templates/spa.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /titleFetcher/README.md: -------------------------------------------------------------------------------- 1 | # Title fetcher 2 | ブックマークエントリーを作るときに、URLにアクセスしてタイトルを取ってきます。 3 | 4 | ```go 5 | type TitleFetcher interface { 6 | Fetch(url string) (string, error) 7 | } 8 | ``` 9 | -------------------------------------------------------------------------------- /titleFetcher/title_fetcher.go: -------------------------------------------------------------------------------- 1 | package titleFetcher 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | type TitleFetcher interface { 11 | Fetch(url string) (string, error) 12 | } 13 | 14 | type titleFetcher struct { 15 | } 16 | 17 | func New() TitleFetcher { 18 | return &titleFetcher{} 19 | } 20 | 21 | func (tf *titleFetcher) Fetch(url string) (string, error) { 22 | res, err := http.Get(url) 23 | if err != nil { 24 | return "", err 25 | } 26 | defer res.Body.Close() 27 | scanner := bufio.NewScanner(res.Body) 28 | for scanner.Scan() { 29 | line := scanner.Text() 30 | i := strings.Index(line, "") 31 | if i == -1 { 32 | continue 33 | } 34 | line = line[i+7:] 35 | j := strings.Index(line, "") 36 | if j == -1 { 37 | return "", errors.New("unmatching title tag") 38 | } 39 | return line[:j], nil 40 | } 41 | return "", errors.New("title not found") 42 | } 43 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # UI 2 | 3 | フロントエンドは[TypeScript](http://www.typescriptlang.org)と[SCSS](https://sass-lang.com)によって開発する。 4 | 5 | ソースコードは`src/`ディレクトリに置く。 6 | 7 | ## ビルド 8 | 9 | TypeScriptとSCSSを[Webpack](https://webpack.js.org)を使ってビルドする。設定は`webpack.config.js`にある。 10 | -------------------------------------------------------------------------------- /ui/src/components/__generated__/BookmarkListFragment.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL fragment: BookmarkListFragment 8 | // ==================================================== 9 | 10 | export interface BookmarkListFragment_bookmarks_user { 11 | name: string; 12 | } 13 | 14 | export interface BookmarkListFragment_bookmarks_entry { 15 | id: string; 16 | title: string; 17 | url: string; 18 | } 19 | 20 | export interface BookmarkListFragment_bookmarks { 21 | id: string; 22 | user: BookmarkListFragment_bookmarks_user; 23 | entry: BookmarkListFragment_bookmarks_entry; 24 | comment: string; 25 | } 26 | 27 | export interface BookmarkListFragment { 28 | name: string; 29 | bookmarks: BookmarkListFragment_bookmarks[]; 30 | } 31 | 32 | /* tslint:disable */ 33 | // This file was automatically generated and should not be edited. 34 | 35 | //============================================================== 36 | // START Enums and Input Objects 37 | //============================================================== 38 | 39 | //============================================================== 40 | // END Enums and Input Objects 41 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/BookmarkListItemFragment.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL fragment: BookmarkListItemFragment 8 | // ==================================================== 9 | 10 | export interface BookmarkListItemFragment_user { 11 | name: string; 12 | } 13 | 14 | export interface BookmarkListItemFragment_entry { 15 | id: string; 16 | title: string; 17 | url: string; 18 | } 19 | 20 | export interface BookmarkListItemFragment { 21 | user: BookmarkListItemFragment_user; 22 | entry: BookmarkListItemFragment_entry; 23 | comment: string; 24 | } 25 | 26 | /* tslint:disable */ 27 | // This file was automatically generated and should not be edited. 28 | 29 | //============================================================== 30 | // START Enums and Input Objects 31 | //============================================================== 32 | 33 | //============================================================== 34 | // END Enums and Input Objects 35 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/CreateBookmark.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL mutation operation: CreateBookmark 8 | // ==================================================== 9 | 10 | export interface CreateBookmark_createBookmark_user { 11 | name: string; 12 | } 13 | 14 | export interface CreateBookmark_createBookmark_entry { 15 | id: string; 16 | title: string; 17 | url: string; 18 | } 19 | 20 | export interface CreateBookmark_createBookmark { 21 | id: string; 22 | user: CreateBookmark_createBookmark_user; 23 | entry: CreateBookmark_createBookmark_entry; 24 | comment: string; 25 | } 26 | 27 | export interface CreateBookmark { 28 | createBookmark: CreateBookmark_createBookmark; 29 | } 30 | 31 | export interface CreateBookmarkVariables { 32 | url: string; 33 | comment: string; 34 | } 35 | 36 | /* tslint:disable */ 37 | // This file was automatically generated and should not be edited. 38 | 39 | //============================================================== 40 | // START Enums and Input Objects 41 | //============================================================== 42 | 43 | //============================================================== 44 | // END Enums and Input Objects 45 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/DeleteBookmark.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL mutation operation: DeleteBookmark 8 | // ==================================================== 9 | 10 | export interface DeleteBookmark { 11 | deleteBookmark: boolean; 12 | } 13 | 14 | export interface DeleteBookmarkVariables { 15 | bookmarkId: string; 16 | } 17 | 18 | /* tslint:disable */ 19 | // This file was automatically generated and should not be edited. 20 | 21 | //============================================================== 22 | // START Enums and Input Objects 23 | //============================================================== 24 | 25 | //============================================================== 26 | // END Enums and Input Objects 27 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/EntryBookmarkFragment.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL fragment: EntryBookmarkFragment 8 | // ==================================================== 9 | 10 | export interface EntryBookmarkFragment_user { 11 | name: string; 12 | } 13 | 14 | export interface EntryBookmarkFragment { 15 | user: EntryBookmarkFragment_user; 16 | comment: string; 17 | } 18 | 19 | /* tslint:disable */ 20 | // This file was automatically generated and should not be edited. 21 | 22 | //============================================================== 23 | // START Enums and Input Objects 24 | //============================================================== 25 | 26 | //============================================================== 27 | // END Enums and Input Objects 28 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/EntryDetailFragment.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL fragment: EntryDetailFragment 8 | // ==================================================== 9 | 10 | export interface EntryDetailFragment_bookmarks_user { 11 | name: string; 12 | } 13 | 14 | export interface EntryDetailFragment_bookmarks { 15 | id: string; 16 | user: EntryDetailFragment_bookmarks_user; 17 | comment: string; 18 | } 19 | 20 | export interface EntryDetailFragment { 21 | title: string; 22 | url: string; 23 | bookmarks: EntryDetailFragment_bookmarks[]; 24 | } 25 | 26 | /* tslint:disable */ 27 | // This file was automatically generated and should not be edited. 28 | 29 | //============================================================== 30 | // START Enums and Input Objects 31 | //============================================================== 32 | 33 | //============================================================== 34 | // END Enums and Input Objects 35 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/EntryListFragment.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL fragment: EntryListFragment 8 | // ==================================================== 9 | 10 | export interface EntryListFragment { 11 | id: string; 12 | title: string; 13 | url: string; 14 | } 15 | 16 | /* tslint:disable */ 17 | // This file was automatically generated and should not be edited. 18 | 19 | //============================================================== 20 | // START Enums and Input Objects 21 | //============================================================== 22 | 23 | //============================================================== 24 | // END Enums and Input Objects 25 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/EntryListItemFragment.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL fragment: EntryListItemFragment 8 | // ==================================================== 9 | 10 | export interface EntryListItemFragment { 11 | id: string; 12 | title: string; 13 | url: string; 14 | } 15 | 16 | /* tslint:disable */ 17 | // This file was automatically generated and should not be edited. 18 | 19 | //============================================================== 20 | // START Enums and Input Objects 21 | //============================================================== 22 | 23 | //============================================================== 24 | // END Enums and Input Objects 25 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/GetEntry.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL query operation: GetEntry 8 | // ==================================================== 9 | 10 | export interface GetEntry_getEntry_bookmarks_user { 11 | name: string; 12 | } 13 | 14 | export interface GetEntry_getEntry_bookmarks { 15 | id: string; 16 | user: GetEntry_getEntry_bookmarks_user; 17 | comment: string; 18 | } 19 | 20 | export interface GetEntry_getEntry { 21 | id: string; 22 | title: string; 23 | url: string; 24 | bookmarks: GetEntry_getEntry_bookmarks[]; 25 | } 26 | 27 | export interface GetEntry { 28 | getEntry: GetEntry_getEntry; 29 | } 30 | 31 | export interface GetEntryVariables { 32 | entryId: string; 33 | } 34 | 35 | /* tslint:disable */ 36 | // This file was automatically generated and should not be edited. 37 | 38 | //============================================================== 39 | // START Enums and Input Objects 40 | //============================================================== 41 | 42 | //============================================================== 43 | // END Enums and Input Objects 44 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/GetVisitor.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL query operation: GetVisitor 8 | // ==================================================== 9 | 10 | export interface GetVisitor_visitor_bookmarks_user { 11 | name: string; 12 | } 13 | 14 | export interface GetVisitor_visitor_bookmarks_entry { 15 | id: string; 16 | title: string; 17 | url: string; 18 | } 19 | 20 | export interface GetVisitor_visitor_bookmarks { 21 | id: string; 22 | user: GetVisitor_visitor_bookmarks_user; 23 | entry: GetVisitor_visitor_bookmarks_entry; 24 | comment: string; 25 | } 26 | 27 | export interface GetVisitor_visitor { 28 | name: string; 29 | bookmarks: GetVisitor_visitor_bookmarks[]; 30 | } 31 | 32 | export interface GetVisitor { 33 | visitor: GetVisitor_visitor; 34 | } 35 | 36 | /* tslint:disable */ 37 | // This file was automatically generated and should not be edited. 38 | 39 | //============================================================== 40 | // START Enums and Input Objects 41 | //============================================================== 42 | 43 | //============================================================== 44 | // END Enums and Input Objects 45 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/__generated__/ListEntries.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /* tslint:disable */ 4 | // This file was automatically generated and should not be edited. 5 | 6 | // ==================================================== 7 | // GraphQL query operation: ListEntries 8 | // ==================================================== 9 | 10 | export interface ListEntries_listEntries { 11 | id: string; 12 | title: string; 13 | url: string; 14 | } 15 | 16 | export interface ListEntries { 17 | listEntries: ListEntries_listEntries[]; 18 | } 19 | 20 | /* tslint:disable */ 21 | // This file was automatically generated and should not be edited. 22 | 23 | //============================================================== 24 | // START Enums and Input Objects 25 | //============================================================== 26 | 27 | //============================================================== 28 | // END Enums and Input Objects 29 | //============================================================== -------------------------------------------------------------------------------- /ui/src/components/add.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Mutation, MutationUpdaterFn} from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | 5 | import {bookmarkListItemFragment} from "./bookmarks"; 6 | import {CreateBookmark, CreateBookmarkVariables} from "./__generated__/CreateBookmark"; 7 | import {query as getVisitorQuery} from "./me"; 8 | import {GetVisitor} from "./__generated__/GetVisitor"; 9 | 10 | const mutation = gql`mutation CreateBookmark($url: String!, $comment: String!) { 11 | createBookmark(url: $url, comment: $comment) { 12 | id 13 | ...BookmarkListItemFragment 14 | } 15 | } 16 | ${bookmarkListItemFragment} 17 | `; 18 | 19 | const updateBookmarks: MutationUpdaterFn = (cache, result) => { 20 | const visitor = cache.readQuery({ query: getVisitorQuery }); 21 | const { data } = result; 22 | if (visitor && data) { 23 | const bookmarks = [...visitor.visitor.bookmarks]; 24 | const found = bookmarks.findIndex(bookmark => bookmark.id === data.createBookmark.id); 25 | if (found !== -1) { 26 | bookmarks[found] = data.createBookmark; 27 | } else { 28 | bookmarks.unshift(data.createBookmark); 29 | } 30 | const newVisitor = { 31 | visitor: { 32 | ...visitor.visitor, 33 | bookmarks, 34 | } 35 | }; 36 | cache.writeQuery({ query: getVisitorQuery, data: newVisitor }); 37 | } 38 | }; 39 | 40 | export const AddBookmark: React.StatelessComponent = () => ( 41 |
42 | mutation={mutation} update={updateBookmarks}> 43 | {(createBookmark) => { 44 | return { 45 | createBookmark({ variables: { url, comment } }) 46 | }} />; 47 | }} 48 | 49 |
50 | ); 51 | 52 | 53 | interface BookmarkFormProps { 54 | create: (url: string, comment: string) => void; 55 | } 56 | interface BookmarkFormState { 57 | url: string 58 | comment: string 59 | } 60 | class BookmarkForm extends React.PureComponent { 61 | state = { 62 | url: '', 63 | comment: '', 64 | }; 65 | 66 | private handleInput = (event: React.ChangeEvent) => { 67 | const input = event.currentTarget; 68 | switch (input.name) { 69 | case "url": 70 | this.setState({ 71 | url: input.value, 72 | }); 73 | break; 74 | case "comment": 75 | this.setState({ 76 | comment: input.value, 77 | }); 78 | break; 79 | } 80 | }; 81 | 82 | private handleSubmit = (event: React.FormEvent) => { 83 | event.preventDefault(); 84 | this.props.create(this.state.url, this.state.comment); 85 | }; 86 | 87 | render() { 88 | return ( 89 |
90 | 93 | 96 | 97 |
98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /ui/src/components/app.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {BrowserRouter, Route, Switch} from "react-router-dom"; 3 | 4 | import {ApolloProvider} from "react-apollo"; 5 | import ApolloClient from "apollo-client"; 6 | import {ApolloLink} from "apollo-link"; 7 | import {HttpLink} from "apollo-link-http"; 8 | import {onError} from "apollo-link-error"; 9 | import {InMemoryCache} from "apollo-cache-inmemory"; 10 | 11 | import {Entry} from "./entry"; 12 | import {GlobalHeader} from "./global_header"; 13 | import {Index} from "./index"; 14 | import {Me} from "./me"; 15 | 16 | const client = new ApolloClient({ 17 | link: ApolloLink.from([ 18 | onError(({ graphQLErrors, networkError }) => { 19 | if (graphQLErrors) 20 | graphQLErrors.map(({ message, locations, path }) => 21 | console.log( 22 | `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`, 23 | ), 24 | ); 25 | if (networkError) console.log(`[Network error]: ${networkError}`); 26 | }), 27 | new HttpLink({ 28 | uri: 'http://localhost:8000/query', 29 | credentials: 'same-origin', 30 | }) 31 | ]), 32 | cache: new InMemoryCache(), 33 | }); 34 | 35 | export const App: React.StatelessComponent = () => ( 36 | 37 | 38 | <> 39 | 40 |
41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 |
49 |
50 | ); 51 | -------------------------------------------------------------------------------- /ui/src/components/bookmarks.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Link} from "react-router-dom"; 3 | import gql from "graphql-tag"; 4 | 5 | import {BookmarkListFragment} from "./__generated__/BookmarkListFragment"; 6 | import {BookmarkListItemFragment} from "./__generated__/BookmarkListItemFragment"; 7 | 8 | export const bookmarkListItemFragment = gql`fragment BookmarkListItemFragment on Bookmark { 9 | user { 10 | name 11 | } 12 | entry { 13 | id 14 | title 15 | url 16 | } 17 | comment 18 | } 19 | `; 20 | 21 | interface BookmarkListItemProps { 22 | bookmark: BookmarkListItemFragment 23 | deleteBookmark?: () => void 24 | } 25 | const BookmarkListItem: React.StatelessComponent = ({ bookmark, deleteBookmark }) => ( 26 |
27 |
28 | {bookmark.entry.title} 29 | - 30 | {bookmark.entry.url} 31 |
32 |
33 |

{bookmark.user.name}

34 |

{bookmark.comment}

35 | {deleteBookmark && } 36 |
37 |
38 | ); 39 | 40 | export const bookmarkListFragment = gql`fragment BookmarkListFragment on User { 41 | name 42 | bookmarks { 43 | id 44 | ...BookmarkListItemFragment 45 | } 46 | } 47 | ${bookmarkListItemFragment} 48 | `; 49 | 50 | interface BookmarkListProps { 51 | user: BookmarkListFragment 52 | deleteBookmark?: (bookmarkId: string) => void 53 | } 54 | export const BookmarkList: React.StatelessComponent = ({ user, deleteBookmark }) => ( 55 |
56 |

{user.name}‘s bookmarks

57 |
    58 | {user.bookmarks.map(bookmark => (
  • 59 | { deleteBookmark(bookmark.id) } : undefined} /> 60 |
  • ))} 61 |
62 |
63 | ); 64 | -------------------------------------------------------------------------------- /ui/src/components/entries.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Link} from "react-router-dom"; 3 | import gql from "graphql-tag"; 4 | 5 | import {EntryListItemFragment} from "./__generated__/EntryListItemFragment"; 6 | import {EntryListFragment} from "./__generated__/EntryListFragment"; 7 | 8 | const entryListItemFragment = gql`fragment EntryListItemFragment on Entry { 9 | id 10 | title 11 | url 12 | }`; 13 | 14 | interface EntryListItemProps { 15 | entry: EntryListItemFragment 16 | } 17 | export const EntryListItem: React.StatelessComponent = ({ entry }) => ( 18 |
19 | {entry.title} 20 | - 21 | {entry.url} 22 |
23 | ); 24 | 25 | export const entryListFragment = gql`fragment EntryListFragment on Entry { 26 | id 27 | ...EntryListItemFragment 28 | } 29 | ${entryListItemFragment} 30 | `; 31 | 32 | interface EntryListProps { 33 | entries: EntryListFragment[] 34 | } 35 | export const EntryList: React.StatelessComponent = ({ entries }) => ( 36 |
    37 | {entries.map(entry => (
  • ))} 38 |
39 | ); 40 | -------------------------------------------------------------------------------- /ui/src/components/entry.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {RouteComponentProps} from "react-router"; 3 | import {Query} from "react-apollo"; 4 | import gql from "graphql-tag"; 5 | 6 | import {EntryDetail, entryDetailFragment} from "./entry_detail"; 7 | import {GetEntry, GetEntryVariables} from "./__generated__/GetEntry"; 8 | 9 | const query = gql`query GetEntry($entryId: ID!) { 10 | getEntry(entryId: $entryId) { 11 | id 12 | ...EntryDetailFragment 13 | } 14 | } 15 | ${entryDetailFragment} 16 | `; 17 | 18 | interface RouteProps { 19 | entryId: string 20 | } 21 | 22 | export const Entry: React.StatelessComponent> = ({ match }) => ( 23 |
24 | query={query} variables={{ entryId: match.params.entryId }}> 25 | {result => { 26 | if (result.error) { 27 | return

Error: {result.error.message}

28 | } 29 | if (result.loading) { 30 | return

Loading

31 | } 32 | const {data} = result; 33 | return ; 34 | }} 35 | 36 |
37 | ); -------------------------------------------------------------------------------- /ui/src/components/entry_detail.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import gql from "graphql-tag"; 3 | 4 | import {EntryBookmarkFragment} from "./__generated__/EntryBookmarkFragment"; 5 | import {EntryDetailFragment} from "./__generated__/EntryDetailFragment"; 6 | 7 | const entryBookmarkFragment = gql`fragment EntryBookmarkFragment on Bookmark { 8 | user { 9 | name 10 | } 11 | comment 12 | } 13 | `; 14 | 15 | interface EntryBookmarkProps { 16 | bookmark: EntryBookmarkFragment 17 | } 18 | const EntryBookmark: React.StatelessComponent = ({ bookmark }) => ( 19 |
20 |

{bookmark.user.name}

21 |

{bookmark.comment}

22 |
23 | ); 24 | 25 | 26 | export const entryDetailFragment = gql`fragment EntryDetailFragment on Entry { 27 | title 28 | url 29 | bookmarks { 30 | id 31 | ...EntryBookmarkFragment 32 | } 33 | } 34 | ${entryBookmarkFragment} 35 | `; 36 | 37 | interface EntryDetailProps { 38 | entry: EntryDetailFragment 39 | } 40 | export const EntryDetail: React.StatelessComponent = ({ entry }) => ( 41 |
42 |

{entry.title}

43 |

{entry.url}

44 |
    45 | {entry.bookmarks.map(bookmark => (
  • 46 | 47 |
  • ))} 48 |
49 |
50 | ); 51 | -------------------------------------------------------------------------------- /ui/src/components/global_header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {NavLink} from "react-router-dom"; 3 | 4 | export const GlobalHeader = () => ( 5 |
6 |

Bookmark

7 | 13 |
14 | ); -------------------------------------------------------------------------------- /ui/src/components/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Query} from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | 5 | import {EntryList, entryListFragment} from "./entries"; 6 | import {ListEntries} from "./__generated__/ListEntries"; 7 | 8 | const query = gql`query ListEntries { 9 | listEntries { 10 | ...EntryListFragment 11 | } 12 | } 13 | ${entryListFragment} 14 | `; 15 | 16 | export const Index: React.StatelessComponent = () => ( 17 |
18 |

Entries

19 | query={query}> 20 | {result => { 21 | if (result.error) { 22 | return

Error: {result.error.message}

23 | } 24 | if (result.loading) { 25 | return

Loading

26 | } 27 | const { data } = result; 28 | return ; 29 | }} 30 | 31 |
32 | ); -------------------------------------------------------------------------------- /ui/src/components/me.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Mutation, MutationUpdaterFn, Query} from "react-apollo"; 3 | import gql from "graphql-tag"; 4 | 5 | import {AddBookmark} from "./add"; 6 | import {BookmarkList, bookmarkListFragment} from "./bookmarks"; 7 | import {GetVisitor} from "./__generated__/GetVisitor"; 8 | import {DeleteBookmark, DeleteBookmarkVariables} from "./__generated__/DeleteBookmark"; 9 | import {CreateBookmark} from "./__generated__/CreateBookmark"; 10 | 11 | export const query = gql`query GetVisitor { 12 | visitor { 13 | ...BookmarkListFragment 14 | } 15 | } 16 | ${bookmarkListFragment} 17 | `; 18 | 19 | export const deleteBookmark = gql`mutation DeleteBookmark($bookmarkId: ID!) { 20 | deleteBookmark(bookmarkId: $bookmarkId) 21 | }`; 22 | 23 | const updateBookmarks: (bookmarkId: string) => MutationUpdaterFn = (bookmarkId) => (cache, result) => { 24 | const visitor = cache.readQuery({ query }); 25 | const { data } = result; 26 | if (visitor && data) { 27 | const bookmarks = [...visitor.visitor.bookmarks].filter(bookmark => bookmark.id !== bookmarkId); 28 | const newVisitor = { 29 | visitor: { 30 | ...visitor.visitor, 31 | bookmarks, 32 | } 33 | }; 34 | cache.writeQuery({query, data: newVisitor}); 35 | } 36 | }; 37 | 38 | export const Me: React.StatelessComponent = () => ( 39 |
40 | query={query}> 41 | {result => { 42 | if (result.error) { 43 | return

Error: {result.error.message}

44 | } 45 | if (result.loading) { 46 | return

Loading

47 | } 48 | const {data} = result; 49 | return <> 50 | 51 | mutation={deleteBookmark}> 52 | {(deleteBookmark) => { 53 | return 56 | deleteBookmark({ variables: { bookmarkId }, update: updateBookmarks(bookmarkId) })} /> 57 | }} 58 | 59 | ; 60 | }} 61 | 62 |
63 | ); 64 | -------------------------------------------------------------------------------- /ui/src/hello.spec.ts: -------------------------------------------------------------------------------- 1 | import {hello} from "./hello"; 2 | 3 | describe('hello', () => { 4 | it('says Hello, world!', () => { 5 | expect(hello()).toEqual('Hello, world!'); 6 | }) 7 | }); 8 | -------------------------------------------------------------------------------- /ui/src/hello.ts: -------------------------------------------------------------------------------- 1 | export function hello(): string { 2 | return 'Hello, world!'; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0 3%; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/index.ts: -------------------------------------------------------------------------------- 1 | import './index.scss'; 2 | 3 | import {hello} from "./hello"; 4 | 5 | console.log(hello()); 6 | -------------------------------------------------------------------------------- /ui/src/spa.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hatena/go-Intern-Bookmark/aa771af809921a6d8a7e51a2e248c5f2d0dd58df/ui/src/spa.scss -------------------------------------------------------------------------------- /ui/src/spa.tsx: -------------------------------------------------------------------------------- 1 | import "./spa.scss"; 2 | 3 | import React from "react"; 4 | import ReactDOM from "react-dom"; 5 | 6 | import {App} from "./components/app"; 7 | 8 | const container = document.getElementById('container') as HTMLDivElement; 9 | 10 | ReactDOM.render(, container); 11 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | // "outDir": "./", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true, /* Enable all strict type-checking options. */ 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 29 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | 32 | /* Additional Checks */ 33 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 34 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 35 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 36 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 37 | 38 | /* Module Resolution Options */ 39 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 40 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 41 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 42 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 43 | // "typeRoots": [], /* List of folders to include type definitions from. */ 44 | // "types": [], /* Type declaration files to be included in compilation. */ 45 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 46 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 47 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 48 | 49 | /* Source Map Options */ 50 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 51 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 52 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 53 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 54 | 55 | /* Experimental Options */ 56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | } 59 | } -------------------------------------------------------------------------------- /ui/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | 5 | module.exports = { 6 | mode: 'development', 7 | entry: { 8 | main: path.resolve(__dirname, './src/index.ts'), 9 | spa: path.resolve(__dirname, './src/spa.tsx'), 10 | }, 11 | output: { 12 | path: path.resolve(__dirname, '../static'), 13 | filename: '[name].js', 14 | publicPath: '/static/', 15 | }, 16 | resolve: { 17 | extensions: ['.ts', ".tsx", ".js"], 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.tsx?$/, 23 | use: 'ts-loader', 24 | }, 25 | { 26 | test: /\.scss$/, 27 | use: [ 28 | MiniCssExtractPlugin.loader, 29 | 'css-loader', 30 | 'sass-loader', 31 | ], 32 | }, 33 | { 34 | test: /\.(png|jpe?g|gif|svg)$/, 35 | use: [ 36 | { 37 | loader: 'file-loader', 38 | }, 39 | ], 40 | }, 41 | ], 42 | }, 43 | plugins: [ 44 | new MiniCssExtractPlugin({ 45 | filename: '[name].css', 46 | }), 47 | ], 48 | }; 49 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # web層 2 | このパッケージでは、webサーバーのルーティングや認証、テンプレートのレンダリングなどを行います。 3 | 4 | リクエストを受け取ってレスポンスを返すのがwebサーバーです。 5 | Goの `http` パッケージは、webサーバーの動作を表す `Handler` interfaceを提供しています。 6 | ```go 7 | type Handler interface { 8 | ServeHTTP(ResponseWriter, *Request) 9 | } 10 | ``` 11 | 12 | この `http.Handler` を返すサーバーを実装します。 13 | ```go 14 | type Server interface { 15 | Handler() http.Handler 16 | } 17 | 18 | func NewServer(app service.BookmarkApp) Server { 19 | return &server{app: app} 20 | } 21 | 22 | type server struct { 23 | app service.BookmarkApp 24 | } 25 | 26 | func (s *server) Handler() http.Handler { 27 | // ... 28 | } 29 | ``` 30 | 31 | HTTPミドルウェアは、 `http.Handler` を引数にとり `http.Handler` を返す形で実装できます。 32 | 33 | 例えばリクエストのログを出力するミドルウェアは、次のように実装できます (middleware.goの実装は、ステータスコードを表示するためにもう少し複雑になっています)。 34 | ```go 35 | func loggingMiddleware(next http.Handler) http.Handler { 36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 | start := time.Now() 38 | next.ServeHTTP(w, r) 39 | log.Printf("%s %s took %.2fmsec", r.Method, r.URL.Path, 40 | float64(time.Now().Sub(start).Nanoseconds())/1e6) 41 | }) 42 | } 43 | ``` 44 | -------------------------------------------------------------------------------- /web/middleware.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type loggingResponseWriter struct { 10 | http.ResponseWriter 11 | statusCode int 12 | } 13 | 14 | func (lrw *loggingResponseWriter) WriteHeader(code int) { 15 | lrw.statusCode = code 16 | lrw.ResponseWriter.WriteHeader(code) 17 | } 18 | 19 | func loggingMiddleware(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | start := time.Now() 22 | lrw := &loggingResponseWriter{w, http.StatusOK} 23 | next.ServeHTTP(lrw, r) 24 | log.Printf("%s %s took %.2fmsec and returned %d %s", r.Method, r.URL.Path, 25 | float64(time.Now().Sub(start).Nanoseconds())/1e6, 26 | lrw.statusCode, http.StatusText(lrw.statusCode), 27 | ) 28 | }) 29 | } 30 | 31 | func headerMiddleware(next http.Handler) http.Handler { 32 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 | w.Header().Set("X-XSS-Protection", "1; mode=block") 34 | w.Header().Set("X-Content-Type-Options", "nosniff") 35 | w.Header().Set("X-Frame-Options", "DENY") 36 | next.ServeHTTP(w, r) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /web/server.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | //go:generate go-assets-builder --package=web --output=./templates-gen.go --strip-prefix="/templates/" --variable=Templates ../templates 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "html/template" 9 | "io/ioutil" 10 | "net/http" 11 | "strconv" 12 | "time" 13 | 14 | "github.com/dimfeld/httptreemux" 15 | "github.com/justinas/nosurf" 16 | 17 | "github.com/hatena/go-Intern-Bookmark/loader" 18 | "github.com/hatena/go-Intern-Bookmark/model" 19 | "github.com/hatena/go-Intern-Bookmark/resolver" 20 | "github.com/hatena/go-Intern-Bookmark/service" 21 | ) 22 | 23 | type Server interface { 24 | Handler() http.Handler 25 | } 26 | 27 | const sessionKey = "BOOKMARK_SESSION" 28 | 29 | var templates map[string]*template.Template 30 | 31 | func init() { 32 | var err error 33 | templates, err = loadTemplates() 34 | if err != nil { 35 | panic(err) 36 | } 37 | } 38 | 39 | func loadTemplates() (map[string]*template.Template, error) { 40 | templates := make(map[string]*template.Template) 41 | bs, err := ioutil.ReadAll(Templates.Files["main.tmpl"]) 42 | if err != nil { 43 | return nil, err 44 | } 45 | mainTmpl := template.Must(template.New("main.tmpl").Parse(string(bs))) 46 | for fileName, file := range Templates.Files { 47 | bs, err := ioutil.ReadAll(file) 48 | if err != nil { 49 | return nil, err 50 | } 51 | mainTmpl := template.Must(mainTmpl.Clone()) 52 | templates[fileName] = template.Must(mainTmpl.New(fileName).Parse(string(bs))) 53 | } 54 | return templates, nil 55 | } 56 | 57 | func NewServer(app service.BookmarkApp) Server { 58 | return &server{app: app} 59 | } 60 | 61 | type server struct { 62 | app service.BookmarkApp 63 | } 64 | 65 | func (s *server) Handler() http.Handler { 66 | router := httptreemux.New() 67 | handle := func(method, path string, handler http.Handler) { 68 | router.UsingContext().Handler(method, path, 69 | csrfMiddleware(loggingMiddleware(headerMiddleware(handler))), 70 | ) 71 | } 72 | 73 | handle("GET", "/", s.indexHandler()) 74 | handle("GET", "/signup", s.willSignupHandler()) 75 | handle("POST", "/signup", s.signupHandler()) 76 | handle("GET", "/signin", s.willSigninHandler()) 77 | handle("POST", "/signin", s.signinHandler()) 78 | handle("POST", "/signout", s.signoutHandler()) 79 | handle("GET", "/entries/:id", s.entryHandler()) 80 | handle("GET", "/bookmarks", s.bookmarksHandler()) 81 | handle("GET", "/bookmarks/add", s.willAddBookmarkHandler()) 82 | handle("POST", "/bookmarks/add", s.addBookmarkHandler()) 83 | handle("POST", "/bookmarks/:id/delete", s.deleteBookmarksHandler()) 84 | 85 | handle("GET", "/spa/", s.spaHandler()) 86 | handle("GET", "/spa/*", s.spaHandler()) 87 | 88 | handle("GET", "/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) 89 | 90 | handle("GET", "/graphiql", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 91 | templates["graphiql.tmpl"].ExecuteTemplate(w, "graphiql.tmpl", nil) 92 | })) 93 | router.UsingContext().Handler("POST", "/query", 94 | s.attatchLoaderMiddleware(s.resolveUserMiddleware( 95 | loggingMiddleware(headerMiddleware(resolver.NewHandler(s.app))), 96 | )), 97 | ) 98 | 99 | return router 100 | } 101 | 102 | func (s *server) getParams(r *http.Request, name string) string { 103 | return httptreemux.ContextParams(r.Context())[name] 104 | } 105 | 106 | var csrfMiddleware = func(next http.Handler) http.Handler { 107 | return nosurf.New(next) 108 | } 109 | 110 | var csrfToken = func(r *http.Request) string { 111 | return nosurf.Token(r) 112 | } 113 | 114 | func (s *server) renderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, data map[string]interface{}) { 115 | if data == nil { 116 | data = make(map[string]interface{}) 117 | } 118 | data["CSRFToken"] = csrfToken(r) 119 | err := templates[tmpl].ExecuteTemplate(w, "main.tmpl", data) 120 | if err != nil { 121 | http.Error(w, err.Error(), http.StatusInternalServerError) 122 | } 123 | } 124 | 125 | func (s *server) findUser(r *http.Request) (user *model.User) { 126 | cookie, err := r.Cookie(sessionKey) 127 | if err == nil && cookie.Value != "" { 128 | user, _ = s.app.FindUserByToken(cookie.Value) 129 | } 130 | return 131 | } 132 | 133 | // Middleware for fetch user from session key for GraphQL 134 | func (s *server) resolveUserMiddleware(next http.Handler) http.Handler { 135 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 136 | user := s.findUser(r) 137 | next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "user", user))) 138 | }) 139 | } 140 | 141 | // Middleware for attaching data loaders for GraphQL 142 | func (s *server) attatchLoaderMiddleware(next http.Handler) http.Handler { 143 | loaders := loader.New(s.app) 144 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 145 | next.ServeHTTP(w, r.WithContext(loaders.Attach(r.Context()))) 146 | }) 147 | } 148 | 149 | func (s *server) indexHandler() http.Handler { 150 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 151 | user := s.findUser(r) 152 | entries, err := s.app.ListEntries(1, 10) 153 | if err != nil { 154 | http.Error(w, err.Error(), http.StatusInternalServerError) 155 | return 156 | } 157 | entryIDs := make([]uint64, len(entries)) 158 | for i, entry := range entries { 159 | entryIDs[i] = entry.ID 160 | } 161 | bookmarkCounts, err := s.app.BookmarkCountsByEntryIds(entryIDs) 162 | if err != nil { 163 | http.Error(w, err.Error(), http.StatusInternalServerError) 164 | return 165 | } 166 | type EntryWithCount struct { 167 | *model.Entry 168 | Count uint64 169 | } 170 | entryWithCount := make([]EntryWithCount, len(entries)) 171 | for i, e := range entries { 172 | entryWithCount[i] = EntryWithCount{Entry: e, Count: bookmarkCounts[e.ID]} 173 | } 174 | s.renderTemplate(w, r, "index.tmpl", map[string]interface{}{ 175 | "User": user, 176 | "Entries": entryWithCount, 177 | }) 178 | }) 179 | } 180 | 181 | func (s *server) willSignupHandler() http.Handler { 182 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 183 | s.renderTemplate(w, r, "signup.tmpl", nil) 184 | }) 185 | } 186 | 187 | func (s *server) signupHandler() http.Handler { 188 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 189 | name, password := r.FormValue("name"), r.FormValue("password") 190 | if err := s.app.CreateNewUser(name, password); err != nil { 191 | http.Error(w, err.Error(), http.StatusInternalServerError) 192 | return 193 | } 194 | user, err := s.app.FindUserByName(name) 195 | if err != nil { 196 | http.Error(w, err.Error(), http.StatusInternalServerError) 197 | return 198 | } 199 | expiresAt := time.Now().Add(24 * time.Hour) 200 | token, err := s.app.CreateNewToken(user.ID, expiresAt) 201 | if err != nil { 202 | http.Error(w, err.Error(), http.StatusInternalServerError) 203 | return 204 | } 205 | http.SetCookie(w, &http.Cookie{ 206 | Name: sessionKey, 207 | Value: token, 208 | Expires: expiresAt, 209 | }) 210 | http.Redirect(w, r, "/", http.StatusSeeOther) 211 | }) 212 | } 213 | 214 | func (s *server) willSigninHandler() http.Handler { 215 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 216 | s.renderTemplate(w, r, "signin.tmpl", nil) 217 | }) 218 | } 219 | 220 | func (s *server) signinHandler() http.Handler { 221 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 222 | name, password := r.FormValue("name"), r.FormValue("password") 223 | if ok, err := s.app.LoginUser(name, password); err != nil || !ok { 224 | http.Error(w, "user not found or invalid password", http.StatusBadRequest) 225 | return 226 | } 227 | user, err := s.app.FindUserByName(name) 228 | if err != nil { 229 | http.Error(w, err.Error(), http.StatusInternalServerError) 230 | return 231 | } 232 | expiresAt := time.Now().Add(24 * time.Hour) 233 | token, err := s.app.CreateNewToken(user.ID, expiresAt) 234 | if err != nil { 235 | http.Error(w, err.Error(), http.StatusInternalServerError) 236 | return 237 | } 238 | http.SetCookie(w, &http.Cookie{ 239 | Name: sessionKey, 240 | Value: token, 241 | Expires: expiresAt, 242 | }) 243 | http.Redirect(w, r, "/", http.StatusSeeOther) 244 | }) 245 | } 246 | 247 | func (s *server) signoutHandler() http.Handler { 248 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 249 | http.SetCookie(w, &http.Cookie{ 250 | Name: sessionKey, 251 | Value: "", 252 | Expires: time.Unix(0, 0), 253 | }) 254 | http.Redirect(w, r, "/", http.StatusSeeOther) 255 | }) 256 | } 257 | 258 | func (s *server) entryHandler() http.Handler { 259 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 260 | entryID, err := strconv.ParseUint(s.getParams(r, "id"), 10, 64) 261 | if err != nil { 262 | http.Error(w, "invalid entry id", http.StatusBadRequest) 263 | return 264 | } 265 | user := s.findUser(r) 266 | entry, err := s.app.FindEntryByID(entryID) 267 | if err != nil { 268 | http.Error(w, err.Error(), http.StatusNotFound) 269 | return 270 | } 271 | bookmarks, err := s.app.ListBookmarksByEntryID(entryID, 1, 10) 272 | if err != nil { 273 | http.Error(w, err.Error(), http.StatusInternalServerError) 274 | return 275 | } 276 | userIDs := make([]uint64, len(bookmarks)) 277 | for i, bookmark := range bookmarks { 278 | userIDs[i] = bookmark.UserID 279 | } 280 | users, err := s.app.ListUsersByIDs(userIDs) 281 | if err != nil { 282 | http.Error(w, err.Error(), http.StatusInternalServerError) 283 | return 284 | } 285 | type BookmarkWithUser struct { 286 | Bookmark *model.Bookmark 287 | User *model.User 288 | } 289 | bookmarkWithUsers := make([]BookmarkWithUser, len(bookmarks)) 290 | for i, bookmark := range bookmarks { 291 | bookmarkWithUsers[i].Bookmark = bookmark 292 | for _, user := range users { 293 | if bookmark.UserID == user.ID { 294 | bookmarkWithUsers[i].User = user 295 | break 296 | } 297 | } 298 | } 299 | s.renderTemplate(w, r, "entry.tmpl", map[string]interface{}{ 300 | "User": user, 301 | "Entry": entry, 302 | "Bookmarks": bookmarkWithUsers, 303 | }) 304 | }) 305 | } 306 | 307 | func (s *server) bookmarksHandler() http.Handler { 308 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 309 | user := s.findUser(r) 310 | if user == nil { 311 | http.Redirect(w, r, "/", http.StatusSeeOther) 312 | return 313 | } 314 | bookmarks, err := s.app.ListBookmarksByUserID(user.ID, 1, 10) 315 | if err != nil { 316 | http.Error(w, err.Error(), http.StatusInternalServerError) 317 | return 318 | } 319 | entryIDs := make([]uint64, len(bookmarks)) 320 | for i, bookmark := range bookmarks { 321 | entryIDs[i] = bookmark.EntryID 322 | } 323 | entries, err := s.app.ListEntriesByIDs(entryIDs) 324 | if err != nil { 325 | http.Error(w, err.Error(), http.StatusInternalServerError) 326 | return 327 | } 328 | type BookmarkWithEntry struct { 329 | Bookmark *model.Bookmark 330 | Entry *model.Entry 331 | } 332 | bookmarkWithEntries := make([]BookmarkWithEntry, len(bookmarks)) 333 | for i, bookmark := range bookmarks { 334 | bookmarkWithEntries[i].Bookmark = bookmark 335 | for _, entry := range entries { 336 | if bookmark.EntryID == entry.ID { 337 | bookmarkWithEntries[i].Entry = entry 338 | break 339 | } 340 | } 341 | } 342 | s.renderTemplate(w, r, "bookmarks.tmpl", map[string]interface{}{ 343 | "User": user, 344 | "Bookmarks": bookmarkWithEntries, 345 | }) 346 | }) 347 | } 348 | 349 | func (s *server) willAddBookmarkHandler() http.Handler { 350 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 351 | user := s.findUser(r) 352 | if user == nil { 353 | http.Redirect(w, r, "/", http.StatusSeeOther) 354 | return 355 | } 356 | s.renderTemplate(w, r, "add.tmpl", map[string]interface{}{ 357 | "User": user, 358 | }) 359 | }) 360 | } 361 | 362 | func (s *server) addBookmarkHandler() http.Handler { 363 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 364 | user := s.findUser(r) 365 | if user == nil { 366 | http.Error(w, "please login", http.StatusBadRequest) 367 | return 368 | } 369 | url, comment := r.FormValue("url"), r.FormValue("comment") 370 | if _, err := s.app.CreateOrUpdateBookmark(user.ID, url, comment); err != nil { 371 | http.Error(w, "failed to create bookmark", http.StatusBadRequest) 372 | return 373 | } 374 | http.Redirect(w, r, "/bookmarks", http.StatusSeeOther) 375 | }) 376 | } 377 | 378 | func (s *server) deleteBookmarksHandler() http.Handler { 379 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 380 | user := s.findUser(r) 381 | if user == nil { 382 | http.Error(w, "please login", http.StatusBadRequest) 383 | return 384 | } 385 | bookmarkID, err := strconv.ParseUint(s.getParams(r, "id"), 10, 64) 386 | if err != nil { 387 | http.Error(w, "invalid bookmark id", http.StatusBadRequest) 388 | return 389 | } 390 | if err := s.app.DeleteBookmark(user.ID, bookmarkID); err != nil { 391 | http.Error(w, fmt.Sprintf("failed to delete bookmark: %+v", err), http.StatusBadRequest) 392 | return 393 | } 394 | http.Redirect(w, r, "/bookmarks", http.StatusSeeOther) 395 | }) 396 | } 397 | 398 | func (s *server) spaHandler() http.Handler { 399 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 400 | templates["spa.tmpl"].ExecuteTemplate(w, "spa.tmpl", nil) 401 | }) 402 | } 403 | -------------------------------------------------------------------------------- /web/server_graphql_bookmark_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestServer_Graphql_Bookmark(t *testing.T) { 14 | app, testServer := newAppServer() 15 | defer testServer.Close() 16 | 17 | user := createTestUser(app) 18 | expiresAt := time.Now().Add(24 * time.Hour) 19 | token, _ := app.CreateNewToken(user.ID, expiresAt) 20 | sessionCookie := &http.Cookie{Name: sessionKey, Value: token, Expires: expiresAt} 21 | url := "https://example.com/" + randomString() 22 | comment := "ブックマークのコメント" + randomString() 23 | resp, respBody := client.PostJSON(testServer.URL+"/query", map[string]interface{}{ 24 | "query": fmt.Sprintf( 25 | `mutation { 26 | createBookmark(url: %q, comment: %q) { 27 | id, 28 | comment 29 | } 30 | }`, 31 | url, comment, 32 | ), 33 | }).WithCookie(sessionCookie).Do() 34 | 35 | assert.Equal(t, http.StatusOK, resp.StatusCode) 36 | var createBookmarkData struct { 37 | Data struct { 38 | CreateBookmark struct { 39 | ID string 40 | Comment string 41 | } 42 | } 43 | } 44 | assert.NoError(t, json.Unmarshal([]byte(respBody), &createBookmarkData)) 45 | assert.Equal(t, comment, createBookmarkData.Data.CreateBookmark.Comment) 46 | 47 | resp, respBody = client.PostJSON(testServer.URL+"/query", map[string]interface{}{ 48 | "query": fmt.Sprintf( 49 | `query { 50 | getBookmark(bookmarkId: %q) { 51 | id, 52 | comment, 53 | user { 54 | id, 55 | name 56 | } 57 | } 58 | }`, 59 | createBookmarkData.Data.CreateBookmark.ID, 60 | ), 61 | }).Do() 62 | 63 | assert.Equal(t, http.StatusOK, resp.StatusCode) 64 | var getBookmarkData struct { 65 | Data struct { 66 | GetBookmark struct { 67 | ID string 68 | Comment string 69 | User struct { 70 | ID string 71 | Name string 72 | } 73 | } 74 | } 75 | } 76 | assert.NoError(t, json.Unmarshal([]byte(respBody), &getBookmarkData)) 77 | assert.Equal(t, comment, getBookmarkData.Data.GetBookmark.Comment) 78 | assert.Equal(t, fmt.Sprint(user.ID), getBookmarkData.Data.GetBookmark.User.ID) 79 | assert.Equal(t, user.Name, getBookmarkData.Data.GetBookmark.User.Name) 80 | 81 | resp, respBody = client.PostJSON(testServer.URL+"/query", map[string]interface{}{ 82 | "query": fmt.Sprintf( 83 | `mutation { 84 | deleteBookmark(bookmarkId: %q) {} 85 | }`, 86 | createBookmarkData.Data.CreateBookmark.ID, 87 | ), 88 | }).WithCookie(sessionCookie).Do() 89 | 90 | assert.Equal(t, http.StatusOK, resp.StatusCode) 91 | var deleteBookmarkData struct{ Data struct{ DeleteBookmark bool } } 92 | assert.NoError(t, json.Unmarshal([]byte(respBody), &deleteBookmarkData)) 93 | assert.True(t, deleteBookmarkData.Data.DeleteBookmark) 94 | } 95 | -------------------------------------------------------------------------------- /web/server_graphql_entry_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestServer_Graphql_Entry(t *testing.T) { 13 | app, testServer := newAppServer() 14 | defer testServer.Close() 15 | 16 | url := "https://example.com/" + randomString() 17 | entry, _ := app.FindOrCreateEntry(url) 18 | 19 | user1 := createTestUser(app) 20 | comment1 := "ブックマークのコメント" + randomString() 21 | app.CreateOrUpdateBookmark(user1.ID, url, comment1) 22 | 23 | user2 := createTestUser(app) 24 | comment2 := "ブックマークのコメント" + randomString() 25 | app.CreateOrUpdateBookmark(user2.ID, url, comment2) 26 | 27 | resp, respBody := client.PostJSON(testServer.URL+"/query", map[string]interface{}{ 28 | "query": fmt.Sprintf( 29 | `query { 30 | getEntry(entryId: %q) { 31 | id, 32 | url, 33 | title, 34 | bookmarks { 35 | id, 36 | comment, 37 | user { 38 | id, 39 | name 40 | } 41 | } 42 | } 43 | }`, 44 | fmt.Sprint(entry.ID), 45 | ), 46 | }).Do() 47 | 48 | assert.Equal(t, http.StatusOK, resp.StatusCode) 49 | 50 | type EntryData struct { 51 | ID string 52 | URL string 53 | Title string 54 | Bookmarks []struct { 55 | ID string 56 | Comment string 57 | User struct { 58 | ID string 59 | Name string 60 | } 61 | } 62 | } 63 | var getEntryData struct { 64 | Data struct { 65 | GetEntry EntryData 66 | } 67 | } 68 | assert.NoError(t, json.Unmarshal([]byte(respBody), &getEntryData)) 69 | assert.Equal(t, fmt.Sprint(entry.ID), getEntryData.Data.GetEntry.ID) 70 | assert.Equal(t, url, getEntryData.Data.GetEntry.URL) 71 | assert.Equal(t, "Example Domain", getEntryData.Data.GetEntry.Title) 72 | assert.Len(t, getEntryData.Data.GetEntry.Bookmarks, 2) 73 | assert.Equal(t, comment2, getEntryData.Data.GetEntry.Bookmarks[0].Comment) 74 | assert.Equal(t, comment1, getEntryData.Data.GetEntry.Bookmarks[1].Comment) 75 | assert.Equal(t, user2.Name, getEntryData.Data.GetEntry.Bookmarks[0].User.Name) 76 | assert.Equal(t, user1.Name, getEntryData.Data.GetEntry.Bookmarks[1].User.Name) 77 | 78 | resp, respBody = client.PostJSON(testServer.URL+"/query", map[string]interface{}{ 79 | "query": fmt.Sprintf( 80 | `query { 81 | listEntries() { 82 | id, 83 | url, 84 | title, 85 | bookmarks { 86 | id, 87 | comment 88 | } 89 | } 90 | }`, 91 | ), 92 | }).Do() 93 | 94 | assert.Equal(t, http.StatusOK, resp.StatusCode) 95 | 96 | var listEntriesData struct { 97 | Data struct { 98 | ListEntries []EntryData 99 | } 100 | } 101 | assert.NoError(t, json.Unmarshal([]byte(respBody), &listEntriesData)) 102 | } 103 | -------------------------------------------------------------------------------- /web/server_graphql_user_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestServer_Graphql_User(t *testing.T) { 14 | app, testServer := newAppServer() 15 | defer testServer.Close() 16 | 17 | user := createTestUser(app) 18 | expiresAt := time.Now().Add(24 * time.Hour) 19 | token, _ := app.CreateNewToken(user.ID, expiresAt) 20 | sessionCookie := &http.Cookie{Name: sessionKey, Value: token, Expires: expiresAt} 21 | 22 | resp, respBody := client.PostJSON(testServer.URL+"/query", map[string]interface{}{ 23 | "query": fmt.Sprintf( 24 | `query { 25 | visitor() { 26 | id, 27 | name 28 | } 29 | }`, 30 | ), 31 | }).WithCookie(sessionCookie).Do() 32 | 33 | assert.Equal(t, http.StatusOK, resp.StatusCode) 34 | var visitorData struct { 35 | Data struct { 36 | Visitor struct { 37 | ID string 38 | Name string 39 | } 40 | } 41 | } 42 | assert.NoError(t, json.Unmarshal([]byte(respBody), &visitorData)) 43 | assert.Equal(t, fmt.Sprint(user.ID), visitorData.Data.Visitor.ID) 44 | assert.Equal(t, user.Name, visitorData.Data.Visitor.Name) 45 | } 46 | -------------------------------------------------------------------------------- /web/server_http_client_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | type httpClient struct { 14 | client *http.Client 15 | req *http.Request 16 | } 17 | 18 | var client = &httpClient{client: &http.Client{ 19 | CheckRedirect: func(_ *http.Request, _ []*http.Request) error { 20 | return http.ErrUseLastResponse 21 | }, 22 | }} 23 | 24 | func (c *httpClient) Get(path string) *httpClient { 25 | req, err := http.NewRequest(http.MethodGet, path, nil) 26 | if err != nil { 27 | panic(err) 28 | } 29 | c.req = req 30 | return c 31 | } 32 | 33 | func (c *httpClient) Post(path string, data map[string]string) *httpClient { 34 | values := url.Values{} 35 | for k, v := range data { 36 | values.Add(k, v) 37 | } 38 | req, err := http.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode())) 39 | if err != nil { 40 | panic(err) 41 | } 42 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 43 | req.Header.Add("Content-Length", strconv.Itoa(len(values.Encode()))) 44 | c.req = req 45 | return c 46 | } 47 | 48 | func (c *httpClient) PostJSON(path string, data interface{}) *httpClient { 49 | jsonBytes, err := json.Marshal(data) 50 | if err != nil { 51 | panic(err) 52 | } 53 | req, err := http.NewRequest(http.MethodPost, path, bytes.NewReader(jsonBytes)) 54 | if err != nil { 55 | panic(err) 56 | } 57 | req.Header.Add("Content-Type", "application/json") 58 | req.Header.Add("Content-Length", strconv.Itoa(len(jsonBytes))) 59 | c.req = req 60 | return c 61 | } 62 | 63 | func (c *httpClient) WithCookie(cookie *http.Cookie) *httpClient { 64 | c.req.AddCookie(cookie) 65 | return c 66 | } 67 | 68 | func (c *httpClient) Do() (*http.Response, string) { 69 | resp, err := c.client.Do(c.req) 70 | if err != nil { 71 | panic(err) 72 | } 73 | respBodyBytes, err := ioutil.ReadAll(resp.Body) 74 | if err != nil { 75 | panic(err) 76 | } 77 | respBody := string(respBodyBytes) 78 | return resp, respBody 79 | } 80 | -------------------------------------------------------------------------------- /web/server_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net/http" 7 | "net/http/httptest" 8 | "strconv" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | 14 | "github.com/hatena/go-Intern-Bookmark/config" 15 | "github.com/hatena/go-Intern-Bookmark/model" 16 | "github.com/hatena/go-Intern-Bookmark/repository" 17 | "github.com/hatena/go-Intern-Bookmark/service" 18 | "github.com/hatena/go-Intern-Bookmark/titleFetcher" 19 | ) 20 | 21 | func init() { 22 | csrfMiddleware = func(next http.Handler) http.Handler { 23 | return next 24 | } 25 | csrfToken = func(r *http.Request) string { 26 | return "" 27 | } 28 | } 29 | 30 | func newAppServer() (service.BookmarkApp, *httptest.Server) { 31 | conf, err := config.Load() 32 | if err != nil { 33 | panic(err) 34 | } 35 | repo, err := repository.New(conf.DbDsn) 36 | if err != nil { 37 | panic(err) 38 | } 39 | app := service.NewApp(repo, titleFetcher.New()) 40 | handler := NewServer(app).Handler() 41 | return app, httptest.NewServer(handler) 42 | } 43 | 44 | func randomString() string { 45 | return strconv.FormatInt(time.Now().Unix()^rand.Int63(), 16) 46 | } 47 | 48 | func createTestUser(app service.BookmarkApp) *model.User { 49 | name := "test name " + randomString() 50 | password := randomString() + randomString() 51 | err := app.CreateNewUser(name, password) 52 | if err != nil { 53 | panic(err) 54 | } 55 | user, err := app.FindUserByName(name) 56 | if err != nil { 57 | panic(err) 58 | } 59 | return user 60 | } 61 | 62 | func TestServer_Index(t *testing.T) { 63 | _, testServer := newAppServer() 64 | defer testServer.Close() 65 | 66 | resp, respBody := client.Get(testServer.URL + "/").Do() 67 | 68 | assert.Equal(t, http.StatusOK, resp.StatusCode) 69 | assert.Contains(t, respBody, `

ブックマーク

`) 70 | assert.Contains(t, respBody, `ユーザー登録`) 71 | } 72 | 73 | func TestServer_Signup(t *testing.T) { 74 | app, testServer := newAppServer() 75 | defer testServer.Close() 76 | 77 | resp, respBody := client.Get(testServer.URL + "/signup").Do() 78 | 79 | assert.Equal(t, http.StatusOK, resp.StatusCode) 80 | assert.Contains(t, respBody, `

ユーザー登録

`) 81 | 82 | name, password := "test name "+randomString(), randomString() 83 | resp, _ = client.Post(testServer.URL+"/signup", map[string]string{ 84 | "name": name, 85 | "password": password, 86 | }).Do() 87 | location := resp.Header.Get("Location") 88 | 89 | assert.Equal(t, http.StatusSeeOther, resp.StatusCode) 90 | assert.Equal(t, "/", location) 91 | 92 | loginSuccess, _ := app.LoginUser(name, password) 93 | assert.Equal(t, true, loginSuccess) 94 | } 95 | 96 | func TestServer_Signin(t *testing.T) { 97 | app, testServer := newAppServer() 98 | defer testServer.Close() 99 | 100 | resp, respBody := client.Get(testServer.URL + "/signin").Do() 101 | 102 | assert.Equal(t, http.StatusOK, resp.StatusCode) 103 | assert.Contains(t, respBody, `

ログイン

`) 104 | 105 | name, password := "test name "+randomString(), randomString() 106 | err := app.CreateNewUser(name, password) 107 | assert.NoError(t, err) 108 | resp, _ = client.Post(testServer.URL+"/signin", map[string]string{ 109 | "name": name, 110 | "password": password, 111 | }).Do() 112 | location := resp.Header.Get("Location") 113 | 114 | assert.Equal(t, http.StatusSeeOther, resp.StatusCode) 115 | assert.Equal(t, "/", location) 116 | } 117 | 118 | func TestServer_Signout(t *testing.T) { 119 | app, testServer := newAppServer() 120 | defer testServer.Close() 121 | 122 | user := createTestUser(app) 123 | expiresAt := time.Now().Add(24 * time.Hour) 124 | token, _ := app.CreateNewToken(user.ID, expiresAt) 125 | sessionCookie := &http.Cookie{Name: sessionKey, Value: token, Expires: expiresAt} 126 | 127 | resp, respBody := client.Get(testServer.URL + "/").WithCookie(sessionCookie).Do() 128 | 129 | assert.Equal(t, http.StatusOK, resp.StatusCode) 130 | assert.Contains(t, respBody, "ユーザー名: "+user.Name) 131 | assert.Contains(t, respBody, ``) 132 | 133 | resp, _ = client.Post(testServer.URL+"/signout", nil).WithCookie(sessionCookie).Do() 134 | location := resp.Header.Get("Location") 135 | 136 | assert.Equal(t, http.StatusSeeOther, resp.StatusCode) 137 | assert.Equal(t, "/", location) 138 | var cookie *http.Cookie 139 | for _, c := range resp.Cookies() { 140 | if c.Name == sessionKey { 141 | cookie = c 142 | } 143 | } 144 | assert.Equal(t, "", cookie.Value) 145 | } 146 | 147 | func TestServer_Entry(t *testing.T) { 148 | app, testServer := newAppServer() 149 | defer testServer.Close() 150 | 151 | user := createTestUser(app) 152 | url := "https://example.com/" + randomString() 153 | comment := "ブックマークのコメント" + randomString() 154 | app.CreateOrUpdateBookmark(user.ID, url, comment) 155 | entry, _ := app.FindOrCreateEntry(url) 156 | 157 | resp, respBody := client.Get( 158 | testServer.URL + "/entries/" + fmt.Sprint(entry.ID), 159 | ).Do() 160 | 161 | assert.Equal(t, http.StatusOK, resp.StatusCode) 162 | assert.Contains(t, respBody, user.Name) 163 | assert.Contains(t, respBody, comment) 164 | } 165 | 166 | func TestServer_AddDeleteBookmark(t *testing.T) { 167 | app, testServer := newAppServer() 168 | defer testServer.Close() 169 | 170 | user := createTestUser(app) 171 | expiresAt := time.Now().Add(24 * time.Hour) 172 | token, _ := app.CreateNewToken(user.ID, expiresAt) 173 | sessionCookie := &http.Cookie{Name: sessionKey, Value: token, Expires: expiresAt} 174 | 175 | resp, respBody := client.Get( 176 | testServer.URL + "/bookmarks/add", 177 | ).WithCookie(sessionCookie).Do() 178 | 179 | assert.Equal(t, http.StatusOK, resp.StatusCode) 180 | assert.Contains(t, respBody, `

ブックマーク追加

`) 181 | 182 | comment := "ブックマークのコメント" + randomString() 183 | resp, _ = client.Post(testServer.URL+"/bookmarks/add", map[string]string{ 184 | "url": "https://example.com/" + randomString(), 185 | "comment": comment, 186 | }).WithCookie(sessionCookie).Do() 187 | location := resp.Header.Get("Location") 188 | 189 | assert.Equal(t, http.StatusSeeOther, resp.StatusCode) 190 | assert.Equal(t, "/bookmarks", location) 191 | 192 | bookmarks, _ := app.ListBookmarksByUserID(user.ID, 1, 10) 193 | assert.Equal(t, comment, bookmarks[0].Comment) 194 | bookmark := bookmarks[0] 195 | 196 | resp, respBody = client.Get( 197 | testServer.URL + "/bookmarks", 198 | ).WithCookie(sessionCookie).Do() 199 | 200 | assert.Equal(t, http.StatusOK, resp.StatusCode) 201 | assert.Contains(t, respBody, comment) 202 | assert.Contains(t, respBody, ``) 203 | 204 | resp, _ = client.Post( 205 | testServer.URL+"/bookmarks/"+fmt.Sprint(bookmark.ID)+"/delete", nil, 206 | ).WithCookie(sessionCookie).Do() 207 | location = resp.Header.Get("Location") 208 | 209 | assert.Equal(t, http.StatusSeeOther, resp.StatusCode) 210 | assert.Equal(t, "/bookmarks", location) 211 | 212 | bookmarks, _ = app.ListBookmarksByUserID(user.ID, 1, 10) 213 | assert.Len(t, bookmarks, 0) 214 | } 215 | --------------------------------------------------------------------------------