diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a875592..2cac38c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,9 @@ # CONTRIBUTING +## Coding + +https://go.dev/doc/effective_go + ## Backend ### Setup diff --git a/backend/.go-version b/backend/.go-version index f124bfa..87b26e8 100644 --- a/backend/.go-version +++ b/backend/.go-version @@ -1 +1 @@ -1.21.9 +1.22.7 diff --git a/backend/Makefile b/backend/Makefile index cfb21ab..70be027 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -51,4 +51,7 @@ dynamo.admin: export PORT="4005" && \ npx dynamodb-admin +openapi: + docker run -it --rm -p 9000:8080 -v $(pwd)/api/openapi.yaml:/usr/share/nginx/html/api/openapi.yaml -e API_URL=http://localhost:9000/api/openapi.yaml swaggerapi/swagger-ui:latest + .PHONY: dev test sec errcheck staticcheck format lint-all lint build build-openapi dynamo.admin diff --git a/backend/README.md b/backend/README.md index c21cc7e..dfb95fa 100644 --- a/backend/README.md +++ b/backend/README.md @@ -9,3 +9,7 @@ curl -X GET http://localhost:6500/openapi.yaml ## API Specifies see: + +## Thank you + + diff --git a/backend/api/openapi.yaml b/backend/api/openapi.yaml index b3a91ef..c8f492b 100644 --- a/backend/api/openapi.yaml +++ b/backend/api/openapi.yaml @@ -1,5 +1,38 @@ components: schemas: + CreateTinyURLBodyBody: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - http://localhost:6500/api/v1/schemas/CreateTinyURLBodyBody.json + format: uri + readOnly: true + type: string + url: + description: URL to shorten + examples: + - http://example.com + type: string + required: + - url + type: object + CreateTinyURLResponseBody: + additionalProperties: false + properties: + $schema: + description: A URL to the JSON Schema for this object. + examples: + - http://localhost:6500/api/v1/schemas/CreateTinyURLResponseBody.json + format: uri + readOnly: true + type: string + id: + type: string + required: + - id + type: object ErrorDetail: additionalProperties: false properties: @@ -57,38 +90,38 @@ components: format: uri type: string type: object - GreetingOutput3Body: + GetInfoTinyURLResponseBody: additionalProperties: false properties: $schema: description: A URL to the JSON Schema for this object. examples: - - http://localhost:6500/api/v1/schemas/GreetingOutput3Body.json + - http://localhost:6500/api/v1/schemas/GetInfoTinyURLResponseBody.json format: uri readOnly: true type: string - message: - description: Greeting message - examples: - - Hello, world! + created_at: + type: string + id: + type: string + original_url: type: string required: - - message + - id + - original_url + - created_at type: object - HealthCheckParams2Body: + HealthCheckResponseBody: additionalProperties: false properties: $schema: description: A URL to the JSON Schema for this object. examples: - - http://localhost:6500/api/v1/schemas/HealthCheckParams2Body.json + - http://localhost:6500/api/v1/schemas/HealthCheckResponseBody.json format: uri readOnly: true type: string message: - description: Greeting message - examples: - - Hello, world! type: string required: - message @@ -99,32 +132,54 @@ components: scheme: bearer type: http info: - title: TinyURL + title: TinyURL API version: 1.0.0 openapi: 3.1.0 paths: - /greeting/{name}: + /health: get: - description: Get a greeting for a person by name. - operationId: get-greeting + description: Check the health of the service. + operationId: health parameters: - - description: Name to greet - example: world - in: path - name: name - required: true + - description: Optional database check parameter + explode: false + in: query + name: q schema: - description: Name to greet - examples: - - world - maxLength: 30 - type: string + description: Optional database check parameter + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/HealthCheckResponseBody" + description: OK + default: + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ErrorModel" + description: Error + summary: Health Check + tags: + - Public + /urls: + post: + description: Create a short URL. + operationId: create-tinyurl + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/CreateTinyURLBodyBody" + required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/GreetingOutput3Body" + $ref: "#/components/schemas/CreateTinyURLResponseBody" description: OK default: content: @@ -132,19 +187,64 @@ paths: schema: $ref: "#/components/schemas/ErrorModel" description: Error - summary: Get a greeting + summary: Create a short URL tags: - - Greetings - /health: + - Public + /urls/:id: get: - description: Check the health of the service. - operationId: health + operationId: get-tinyurl-with-redirect + parameters: + - description: ID of the short URL + in: path + name: id + required: true + schema: + type: string + - in: path + name: id + required: true + schema: + type: string + responses: + "204": + description: No Content + headers: + Location: + schema: + type: string + "301": + description: Redirect to original URL + headers: + Location: + description: Location of the original URL + schema: + format: uri + type: string + "404": + content: + text/plain: + schema: + type: string + description: Short URL not found + summary: Redirect to original URL + tags: + - Public + /urls/info/:id: + get: + description: Get Info tinyurl + operationId: info-tinyurl + parameters: + - in: path + name: id + required: true + schema: + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/HealthCheckParams2Body" + $ref: "#/components/schemas/GetInfoTinyURLResponseBody" description: OK default: content: @@ -152,9 +252,9 @@ paths: schema: $ref: "#/components/schemas/ErrorModel" description: Error - summary: Health Check + summary: Get Info tinyurl tags: - - Greetings + - Public servers: - url: http://localhost:6500/api/v1 diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index c77c8ac..5f3244c 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -9,8 +9,10 @@ import ( "github.com/danielgtaylor/huma/v2/humacli" "github.com/labstack/echo/v4" "github.com/naohito-T/tinyurl/backend/configs" + "github.com/naohito-T/tinyurl/backend/internal/infrastructure" "github.com/naohito-T/tinyurl/backend/internal/rest/middleware" "github.com/naohito-T/tinyurl/backend/internal/rest/router" + appSchema "github.com/naohito-T/tinyurl/backend/schema/api" "github.com/spf13/cobra" ) @@ -25,44 +27,22 @@ type Options struct { Port int `doc:"Port to listen on." short:"p" default:"8888"` } -// /api/v1/openapi.yaml -// initHuma: humaのconfigを初期化 -func initHuma() huma.Config { - config := huma.DefaultConfig(configs.OpenAPITitle, configs.OpenAPIVersion) - config.Servers = []*huma.Server{ - {URL: configs.OpenAPIDocServerPath}, - } - - config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ - "bearer": { - Type: "http", - Scheme: "bearer", - BearerFormat: "JWT", - }, - } - config.DocsPath = "/docs" - return config -} - // publicにわける +// public(誰でもアクセス可能) // user(ログイン必須) // private(管理者) func main() { var api huma.API + var c configs.AppEnvironment + logger := infrastructure.NewLogger() cli := humacli.New(func(hooks humacli.Hooks, opts *Options) { - fmt.Printf("Options are debug:%v host:%v port%v\n", opts.Debug, opts.Host, opts.Port) - e := echo.New() - // configを初期化 - configs.NewAppEnvironment() - // ミドルウェアを適用(すべてのリクエストに対して) - middleware.CustomMiddleware(e) - // これgroup化したやつをnewUserRouterに渡す必要かも - api = humaecho.NewWithGroup(e, e.Group("/api/v1"), initHuma()) - router.NewPublicRouter(api) + c = configs.NewAppEnvironment() + middleware.CustomMiddleware(e, c) + api = router.NewPublicRouter(humaecho.NewWithGroup(e, e.Group("/api/v1"), appSchema.NewHumaConfig()), logger) // 未定義のルート用のキャッチオールハンドラ e.Any("/*", func(c echo.Context) error { return c.JSON(http.StatusNotFound, map[string]string{"message": "route_not_found"}) diff --git a/backend/configs/constructor.go b/backend/configs/constructor.go index 2bb1ca4..3840719 100644 --- a/backend/configs/constructor.go +++ b/backend/configs/constructor.go @@ -10,29 +10,3 @@ const ( // OpenAPIServerPath is the base URL for the OpenAPI spec. OpenAPIDocServerPath = "http://localhost:6500/api/v1" ) - -// OperationID: このAPI操作の一意の識別子。これは、API内で操作を参照する際に使用されます。 -// Method: HTTPメソッドを指定します。この例では http.MethodGet が使われており、これはHTTPのGETリクエストを示します。 -// Path: エンドポイントのURLパスを指定します。ここでは "/greeting/{name}" となっており、{name} はパスパラメータを表しています。 -// Summary: 短い説明文です。APIのドキュメントに表示され、APIの目的を簡潔に説明します。 -// Description: APIエンドポイントの詳細な説明です。ここでは操作の詳細や動作についての追加情報を提供します。 -// Tags: このAPI操作に関連付けられたタグのリストです。これにより、APIドキュメント内で類似の操作をグループ化することができます。 - -// huma.Register(app, huma.Operation{ -// OperationID: "health", -// Method: http.MethodGet, -// Path: Router.Health, -// Summary: "Health Check", -// Description: "Check the health of the service.", -// Tags: []string{"Public"}, -// }, func(_ context.Context, _ *HealthCheckParams) (*HealthCheckQuery, error) { -// resp := &HealthCheckQuery{ -// Body: struct{ -// Message string `json:"message,omitempty" example:"Hello, world!" doc:"Greeting message"` -// }{ -// Message: "ok", -// }, -// } -// fmt.Printf("Health Check: %v\n", resp.Body.Message) -// return resp, nil -// }) diff --git a/backend/configs/environment.go b/backend/configs/environment.go index bbaa261..d1ac42c 100644 --- a/backend/configs/environment.go +++ b/backend/configs/environment.go @@ -8,18 +8,39 @@ import ( ) type AppEnvironment struct { - Stage string `default:"local"` + stage string `default:"local"` + tinyURLCollection string `default:"offline-tinyurls"` } -var newOnceLogger = sync.OnceValue(func() *AppEnvironment { +var newOnceLogger = sync.OnceValue(func() AppEnvironment { var ae AppEnvironment if err := envconfig.Process("", &ae); err != nil { panic(fmt.Sprintf("Failed to process environment config: %v", err)) } - return &ae + return ae }) // SEE: https://pkg.go.dev/github.com/kelseyhightower/envconfig -func NewAppEnvironment() *AppEnvironment { +func NewAppEnvironment() AppEnvironment { return newOnceLogger() } + +func (a *AppEnvironment) IsTest() bool { + return a.stage == "test" +} + +func (a *AppEnvironment) IsLocal() bool { + return a.stage == "local" +} + +func (a *AppEnvironment) IsDev() bool { + return a.stage == "dev" +} + +func (a *AppEnvironment) IsProd() bool { + return a.stage == "prod" +} + +func (a *AppEnvironment) GetTinyURLCollectionName() string { + return a.tinyURLCollection +} diff --git a/backend/configs/router.go b/backend/configs/router.go index 9640fd0..358e721 100644 --- a/backend/configs/router.go +++ b/backend/configs/router.go @@ -10,8 +10,12 @@ type path = string const ( // /api/v1/health Health path = "/health" - // /api/v1/urls + // リダイレクト用エンドポイント GetShortURL path = "/urls/:id" - // /api/v1/urls + // 短縮URLを作成するためのエンドポイント CreateShortURL path = "/urls" + // すべてのURLをリストするためのエンドポイント + ListShortURLs path = "/urls/list" + // 特定の短縮URLの詳細情報を取得 + GetOnlyShortURL path = "/urls/info/:id" ) diff --git a/backend/docker/Dockerfile b/backend/docker/Dockerfile index f3b3f4f..bc9d673 100644 --- a/backend/docker/Dockerfile +++ b/backend/docker/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21.1 +FROM golang:1.22.7 WORKDIR /app diff --git a/backend/go.mod b/backend/go.mod index b85668d..d169910 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/naohito-T/tinyurl/backend -go 1.21 +go 1.22 require ( github.com/aws/aws-sdk-go-v2 v1.26.1 @@ -8,7 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.13.13 github.com/aws/aws-sdk-go-v2/service/dynamodb v1.31.1 github.com/aws/smithy-go v1.20.2 - github.com/danielgtaylor/huma/v2 v2.15.0 + github.com/danielgtaylor/huma/v2 v2.22.1 github.com/go-playground/validator/v10 v10.19.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/labstack/echo/v4 v4.11.4 diff --git a/backend/go.sum b/backend/go.sum index 0ada87f..6f29222 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -33,15 +33,13 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2M github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/danielgtaylor/huma/v2 v2.15.0 h1:26c3hxNT+0xNc8qDLPXNko48qyi31RDFQdhi36gorRI= -github.com/danielgtaylor/huma/v2 v2.15.0/go.mod h1:OdHC/JliXtOrnvHLQTU5qV7WvYRQXwWY1tkl5rLXmuE= +github.com/danielgtaylor/huma/v2 v2.22.1 h1:fXhyjGSj5u5VeI+laa+e+7OxiQsP9RC55/tWZZvI4YA= +github.com/danielgtaylor/huma/v2 v2.22.1/go.mod h1:2NZmGf/A+SstJYQlq0Xp4nsTDCmPvKS2w9vI8c9sf1A= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= -github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= diff --git a/backend/internal/infrastructure/dynamo.go b/backend/internal/infrastructure/dynamo.go new file mode 100644 index 0000000..a8d7ab2 --- /dev/null +++ b/backend/internal/infrastructure/dynamo.go @@ -0,0 +1,93 @@ +package infrastructure + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/naohito-T/tinyurl/backend/configs" +) + +type Client interface { + Get(ctx context.Context, params *dynamodb.GetItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) + Put(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) + Search(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) +} + +type Connection struct { + *dynamodb.Client + ILogger + configs.AppEnvironment +} + +func NewDynamoConnection(logger ILogger, env configs.AppEnvironment) *Connection { + // https://zenn.dev/y16ra/articles/40ff14e8d2a4db + customResolver := aws.EndpointResolverFunc(func(service, region string) (aws.Endpoint, error) { + return aws.Endpoint{ + PartitionID: "aws", + URL: "http://aws:4566", // LocalStackのDynamoDBエンドポイント + SigningRegion: "ap-northeast-1", + }, nil + }) + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithRegion("ap-northeast-1"), + config.WithEndpointResolver(customResolver), + ) + if err != nil { + logger.Error("unable to load SDK config, %v", err) + } + + return &Connection{ + dynamodb.NewFromConfig(cfg), + logger, + env, + } +} + +func (c *Connection) Get(ctx context.Context, params *dynamodb.GetItemInput) (*dynamodb.GetItemOutput, error) { + c.Info("GetItemInput: %v", params) + if result, err := c.GetItem(ctx, &dynamodb.GetItemInput{ + TableName: aws.String(c.GetTinyURLCollectionName()), + Key: params.Key, + }); err != nil { + c.Error("Get error: %v", err) + return nil, err + } else { + return result, nil + } +} + +func (c *Connection) Put(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) { + c.Info("PutItemInput: %v", params) + if result, err := c.PutItem(ctx, &dynamodb.PutItemInput{ + // rateLimitなどテーブルnameは上から渡したほうがいいかも + TableName: aws.String(c.GetTinyURLCollectionName()), + Item: params.Item, + }, optFns...); err != nil { + c.Error("Put error: %v", err) + return nil, err + } else { + return result, nil + } +} + +// // QueryInput の構築 +// +// input := &dynamodb.QueryInput{ +// TableName: aws.String("offline-tinyurls"), +// IndexName: aws.String("OriginalURLIndex"), // OriginalURL に基づいた GSI (グローバルセカンダリインデックス) の名前 +// KeyConditionExpression: aws.String("OriginalURL = :originalURL"), +// ExpressionAttributeValues: map[string]dynamodb.AttributeValue{ +// ":originalURL": {S: aws.String(originalURL)}, +// }, +// } +func (c *Connection) Search(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) { + c.Info("SearchItemInput: %v", params) + if result, err := c.Query(ctx, params, optFns...); err != nil { + c.Error("Query error: %v", err) + return nil, err + } else { + return result, nil + } +} diff --git a/backend/internal/infrastructures/slog/slog.go b/backend/internal/infrastructure/logger.go similarity index 96% rename from backend/internal/infrastructures/slog/slog.go rename to backend/internal/infrastructure/logger.go index 646fc20..fad61c0 100644 --- a/backend/internal/infrastructures/slog/slog.go +++ b/backend/internal/infrastructure/logger.go @@ -1,4 +1,4 @@ -package slog +package infrastructure import ( "log/slog" @@ -33,7 +33,7 @@ type Logger struct { var newOnceLogger = sync.OnceValue(func() *Logger { // これは1.22.0で追加されたもの - // slog.SetLogLoggerLevel(slog.LevelDebug) + slog.SetLogLoggerLevel(slog.LevelDebug) return &Logger{ logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)), } diff --git a/backend/internal/infrastructures/dynamo/dynamo.go b/backend/internal/infrastructures/dynamo/dynamo.go deleted file mode 100644 index 5953c9b..0000000 --- a/backend/internal/infrastructures/dynamo/dynamo.go +++ /dev/null @@ -1,36 +0,0 @@ -package dynamo - -import ( - "context" - "log/slog" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/dynamodb" -) - -type Connection struct { - Conn *dynamodb.Client -} - -func NewDynamoConnection() *Connection { - // https://zenn.dev/y16ra/articles/40ff14e8d2a4db - customResolver := aws.EndpointResolverFunc(func(service, region string) (aws.Endpoint, error) { - return aws.Endpoint{ - PartitionID: "aws", - URL: "http://aws:4566", // LocalStackのDynamoDBエンドポイント - SigningRegion: "ap-northeast-1", - }, nil - }) - cfg, err := config.LoadDefaultConfig(context.TODO(), - config.WithRegion("ap-northeast-1"), - config.WithEndpointResolver(customResolver), - ) - if err != nil { - slog.Error("unable to load SDK config, %v", err) - } - - return &Connection{ - Conn: dynamodb.NewFromConfig(cfg), - } -} diff --git a/backend/internal/repository/dynamo/shorturl.go b/backend/internal/repository/dynamo/shorturl.go index 54ca2fa..be97da2 100644 --- a/backend/internal/repository/dynamo/shorturl.go +++ b/backend/internal/repository/dynamo/shorturl.go @@ -11,8 +11,7 @@ import ( "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" "github.com/naohito-T/tinyurl/backend/domain" - DynamoClient "github.com/naohito-T/tinyurl/backend/internal/infrastructures/dynamo" - "github.com/naohito-T/tinyurl/backend/internal/infrastructures/slog" + "github.com/naohito-T/tinyurl/backend/internal/infrastructure" ) type IShortURLRepository interface { @@ -21,16 +20,16 @@ type IShortURLRepository interface { } type ShortURLRepository struct { - Client *DynamoClient.Connection + *infrastructure.Connection // インターフェースは既に参照型です。これは、インターフェースが背後でポインタとして機能することを意味し、明示的にポインタとして渡す必要はありません。 - logger slog.ILogger + infrastructure.ILogger } -func NewShortURLRepository(client *DynamoClient.Connection, logger slog.ILogger) *ShortURLRepository { +func NewShortURLRepository(client *infrastructure.Connection, logger infrastructure.ILogger) *ShortURLRepository { // &ShortURLRepository{...} によって ShortURLRepository 型の新しいインスタンスがメモリ上に作成され、そのインスタンスのアドレスが返されます return &ShortURLRepository{ - Client: client, - logger: logger, + client, + logger, } } @@ -46,7 +45,7 @@ type ItemKey struct { // 構造体に属することで、構造体が初期されていないと呼び出すことはできないことになる。 func (r *ShortURLRepository) Get(ctx context.Context, hashURL string) (domain.ShortURL, error) { - r.logger.Debug("GetItemInput: %v", hashURL) + r.Debug("GetItemInput: %v", hashURL) itemKey := ItemKey{ ID: hashURL, @@ -58,7 +57,7 @@ func (r *ShortURLRepository) Get(ctx context.Context, hashURL string) (domain.Sh return domain.ShortURL{}, err } - result, err := r.Client.Conn.GetItem(ctx, &dynamodb.GetItemInput{ + result, err := r.GetItem(ctx, &dynamodb.GetItemInput{ TableName: aws.String("offline-tinyurls"), Key: av, }) @@ -85,7 +84,7 @@ func (r *ShortURLRepository) Get(ctx context.Context, hashURL string) (domain.Sh } func (r *ShortURLRepository) Put(ctx context.Context, params *domain.ShortURL) (domain.ShortURL, error) { - r.logger.Info("PutItemInput: %v", params) + r.Info("PutItemInput: %v", params) item := TableItem{ ID: params.ID, @@ -98,7 +97,7 @@ func (r *ShortURLRepository) Put(ctx context.Context, params *domain.ShortURL) ( return domain.ShortURL{}, err // エラー時にゼロ値を返す } - _, err = r.Client.Conn.PutItem(ctx, &dynamodb.PutItemInput{ + _, err = r.PutItem(ctx, &dynamodb.PutItemInput{ TableName: aws.String("offline-tinyurls"), Item: av, }) diff --git a/backend/internal/rest/container/container.go b/backend/internal/rest/container/container.go index 5760db6..3766cda 100644 --- a/backend/internal/rest/container/container.go +++ b/backend/internal/rest/container/container.go @@ -1,22 +1,24 @@ package container import ( - "github.com/naohito-T/tinyurl/backend/internal/infrastructures/dynamo" - "github.com/naohito-T/tinyurl/backend/internal/infrastructures/slog" - repoDynamo "github.com/naohito-T/tinyurl/backend/internal/repository/dynamo" + "github.com/naohito-T/tinyurl/backend/configs" + infra "github.com/naohito-T/tinyurl/backend/internal/infrastructure" + repo "github.com/naohito-T/tinyurl/backend/internal/repository/dynamo" "github.com/naohito-T/tinyurl/backend/internal/usecase" "sync" ) type GuestContainer struct { - URLUsecase *usecase.ShortURLUsecase + *usecase.ShortURLUsecase } var onceGuestContainer = sync.OnceValue(func() *GuestContainer { - dynamoRepo := repoDynamo.NewShortURLRepository(dynamo.NewDynamoConnection(), slog.NewLogger()) + logger := infra.NewLogger() + env := configs.NewAppEnvironment() + dynamoRepo := repo.NewShortURLRepository(infra.NewDynamoConnection(logger, env), logger) return &GuestContainer{ - URLUsecase: usecase.NewURLUsecase(dynamoRepo), + usecase.NewURLUsecase(dynamoRepo, logger), } }) diff --git a/backend/internal/rest/middleware/error/error.go b/backend/internal/rest/handler/error.go similarity index 87% rename from backend/internal/rest/middleware/error/error.go rename to backend/internal/rest/handler/error.go index dc2c9bc..4cd62fb 100644 --- a/backend/internal/rest/middleware/error/error.go +++ b/backend/internal/rest/handler/error.go @@ -1,4 +1,4 @@ -package error +package handler // see: https://go.dev/play/p/TzZE1mdL63_1 // errors.Is()とerrors.As()は、 @@ -12,6 +12,7 @@ import ( "log" "net/http" + "github.com/aws/smithy-go" "github.com/go-playground/validator/v10" "github.com/labstack/echo/v4" "github.com/naohito-T/tinyurl/backend/domain/customerror" @@ -30,12 +31,14 @@ func CustomErrorHandler(err error, c echo.Context) { func buildError(err error, c echo.Context) *customerror.ApplicationError { c.Logger().Error("ビルドエラー実施中") + var ve validator.ValidationErrors + var oe *smithy.OperationError appErr := customerror.ApplicationError{ Status: customerror.UnexpectedCode.Status, Code: customerror.UnexpectedCode.Code, Message: customerror.UnexpectedCode.Message, } - var ve validator.ValidationErrors + if errors.As(err, &ve) { c.Logger().Error("errors.As():Failed at バリデーションエラー発生") return &customerror.ApplicationError{ @@ -44,6 +47,12 @@ func buildError(err error, c echo.Context) *customerror.ApplicationError { Message: "Input validation failed", } } + + // aws-sdk-go-v2のエラー処理 + if errors.As(err, &oe) { + log.Printf("failed to call service: %s, operation: %s, error: %v", oe.Service(), oe.Operation(), oe.Unwrap()) + } + if errors.Is(err, customerror.WrongEmailVerificationErrorInstance) { c.Logger().Error("これがWrongEmailVerificationErrorアプリケーションエラー") } diff --git a/backend/internal/rest/handler/health.go b/backend/internal/rest/handler/health.go index 91458de..8d04035 100644 --- a/backend/internal/rest/handler/health.go +++ b/backend/internal/rest/handler/health.go @@ -2,17 +2,33 @@ package handler // import ( // "context" -// "net/http" -// "github.com/labstack/echo/v4" -// "github.com/naohito-T/tinyurl/backend/domain/customerror" +// "github.com/naohito-T/tinyurl/backend/internal/rest/container" // ) -// // HealthCheckParams はヘルスチェックのパラメータを定義します -// // type HealthCheckParams struct { -// // // CheckDB *string `query:"check_db" validate:"required"` -// // CheckDB *string `query:"check_db"` -// // } +// type HealthCheckHandler struct { +// *container.GuestContainer +// } + +// type IHealthCheckHandler interface { +// HealthHandler(ctx context.Context) (bool error) +// } + +// func NewHealthCheckHandler(c *container.GuestContainer) IHealthCheckHandler { +// return &HealthCheckHandler{ +// c, +// } +// } + +// func (h *HealthCheckHandler) HealthHandler(ctx context.Context) (bool error) { +// h.CreateShortURL() +// } + +// HealthCheckParams はヘルスチェックのパラメータを定義します +// type HealthCheckParams struct { +// // CheckDB *string `query:"check_db" validate:"required"` +// CheckDB *string `query:"check_db"` +// } // func HealthHandler(c echo.Context) error { // h := new(HealthCheckParams) diff --git a/backend/internal/rest/handler/shorturl.go b/backend/internal/rest/handler/shorturl.go index 986f6ab..b9bae22 100644 --- a/backend/internal/rest/handler/shorturl.go +++ b/backend/internal/rest/handler/shorturl.go @@ -4,12 +4,18 @@ import ( "context" "github.com/naohito-T/tinyurl/backend/domain" - "github.com/naohito-T/tinyurl/backend/internal/infrastructures/slog" + "github.com/naohito-T/tinyurl/backend/internal/infrastructure" "github.com/naohito-T/tinyurl/backend/internal/rest/container" ) +// ポイント1: インターフェースに構造体を埋め込むことはできない。逆に他3つはできる。 +// ポイント2: "借り物"のメソッドを自分のものとして使うことができる。 +// ポイント3: 他言語の継承とは異なり、埋め込み先のメンバーに影響を与えない。 +// ポイント4: 埋め込み元と埋め込み先に同じフィールド名が存在するとき、埋め込み先が優先される。 +// 埋め込み(embedding) type ShortURLHandler struct { - container *container.GuestContainer + *container.GuestContainer + infrastructure.ILogger } // IShortURLHandler defines the interface for short URL handler operations. @@ -19,18 +25,19 @@ type IShortURLHandler interface { } // NewShortURLHandler creates a new handler for short URLs. -func NewShortURLHandler(c *container.GuestContainer) IShortURLHandler { +func NewShortURLHandler(c *container.GuestContainer, logger infrastructure.ILogger) IShortURLHandler { return &ShortURLHandler{ - container: c, + c, + logger, } } func (s *ShortURLHandler) GetShortURLHandler(ctx context.Context, hashID string) (domain.ShortURL, error) { - slog.NewLogger().Info("GetShortURLHandler: %v", hashID) - return s.container.URLUsecase.GetByShortURL(ctx, hashID) + s.Info("GetShortURLHandler: %v", hashID) + return s.GetByShortURL(ctx, hashID) } func (s *ShortURLHandler) CreateShortURLHandler(ctx context.Context, originalURL string) (domain.ShortURL, error) { - slog.NewLogger().Info("CreateShortURLHandler: %v", originalURL) - return s.container.URLUsecase.CreateShortURL(ctx, originalURL) + s.Info("CreateShortURLHandler: %v", originalURL) + return s.CreateShortURL(ctx, originalURL) } diff --git a/backend/internal/rest/middleware/middleware.go b/backend/internal/rest/middleware/middleware.go index 9496bfe..7f8d400 100644 --- a/backend/internal/rest/middleware/middleware.go +++ b/backend/internal/rest/middleware/middleware.go @@ -8,25 +8,28 @@ import ( "github.com/labstack/gommon/log" // "github.com/naohito-T/tinyurl/backend/internal/rest/middleware/accesslog" - ehandler "github.com/naohito-T/tinyurl/backend/internal/rest/middleware/error" + "github.com/naohito-T/tinyurl/backend/configs" + "github.com/naohito-T/tinyurl/backend/internal/rest/handler" "github.com/naohito-T/tinyurl/backend/internal/rest/middleware/validator" ) // loggerの考え方 // https://yuya-hirooka.hatenablog.com/entry/2021/10/15/123607 -func CustomMiddleware(e *echo.Echo) { - // Loggerの設定変更 - e.Logger.SetLevel(log.DEBUG) // すべてのログレベルを出力する - +func CustomMiddleware(e *echo.Echo, c configs.AppEnvironment) { + // echo.Loggerの設定変更 + if c.IsLocal() { + e.Logger.SetLevel(log.DEBUG) + } else { + e.Logger.SetLevel(log.INFO) + } // ミドルウェアとルートの設定 // e.Use(middleware.Logger()) // ロギングミドルウェアを使う e.Validator = validator.NewValidator() // e.Use(accesslog.AccessLog()) - // expect this handler is used as fallback unless a more specific is present e.Use(middleware.Recover()) // これでechoのloggerを操作できる。 // e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ // Format: "time=${time_rfc3339_nano}, method=${method}, uri=${uri}, status=${status}\n", // })) - e.HTTPErrorHandler = ehandler.CustomErrorHandler + e.HTTPErrorHandler = handler.CustomErrorHandler } diff --git a/backend/internal/rest/router/public.go b/backend/internal/rest/router/public.go index 0fd5f68..2ce80ca 100644 --- a/backend/internal/rest/router/public.go +++ b/backend/internal/rest/router/public.go @@ -2,17 +2,19 @@ package router import ( "context" + "fmt" "net/http" "github.com/danielgtaylor/huma/v2" "github.com/naohito-T/tinyurl/backend/configs" - "github.com/naohito-T/tinyurl/backend/internal/infrastructures/slog" + "github.com/naohito-T/tinyurl/backend/internal/infrastructure" "github.com/naohito-T/tinyurl/backend/internal/rest/container" "github.com/naohito-T/tinyurl/backend/internal/rest/handler" + "github.com/naohito-T/tinyurl/backend/schema/api" ) type HealthCheckQuery struct { - CheckDB bool `query:"q" doc:"Optional database check parameter"` + CheckDB bool `query:"q" doc:"Optional DynamoDB check parameter"` } type HealthCheckResponse struct { @@ -26,11 +28,8 @@ type GetTinyURLQuery struct { } type GetTinyURLResponse struct { - Body struct { - ID string `json:"id" required:"true"` - OriginalURL string `json:"original_url" required:"true"` - CreatedAt string `json:"created_at" required:"true"` - } + Status int + Url string `header:"Location"` } type CreateTinyURLBody struct { @@ -45,28 +44,36 @@ type CreateTinyURLResponse struct { } } +type GetInfoTinyURLQuery struct { + ID string `path:"id" required:"true"` +} + +type GetInfoTinyURLResponse struct { + Body struct { + ID string `json:"id" required:"true"` + OriginalURL string `json:"original_url" required:"true"` + CreatedAt string `json:"created_at" required:"true"` + } +} + // 今日の課題 -// 1. tinyulrのAPIを作成する(できそう) -// 2. テストを書く -// 3. ドキュメントを書く +// 1. validationのエラーを返す +// 2. openapiのドキュメントを清書する +// 3. テストを書く +// 4. ドキュメントを書く // https://tinyurl.com/app/api/url/create" // NewRouter これもシングルトンにした場合の例が気になる -func NewPublicRouter(app huma.API) { - h := handler.NewShortURLHandler(container.NewGuestContainer()) +func NewPublicRouter(app huma.API, logger infrastructure.ILogger) huma.API { + h := handler.NewShortURLHandler(container.NewGuestContainer(), infrastructure.NewLogger()) // これ見ていつか修正する https://aws.amazon.com/jp/builders-library/implementing-health-checks/ - huma.Register(app, huma.Operation{ - OperationID: "health", - Method: http.MethodGet, - Path: configs.Health, - Summary: "Health Check", - Description: "Check the health of the service.", - Tags: []string{"Public"}, - }, func(_ context.Context, input *struct { + // dynamoDBのヘルスチェックはない(SELECT 1 とかできない) + // publicに開放しているapiのため、レートリミットとかの縛りは必要。 + huma.Register(app, *api.GetHealthAPISchema(), func(_ context.Context, input *struct { HealthCheckQuery }) (*HealthCheckResponse, error) { - slog.NewLogger().Info("Health Check: %v", input.CheckDB) + logger.Info("Health Check:", input.CheckDB) return &HealthCheckResponse{ Body: struct { Message string `json:"message"` @@ -77,28 +84,60 @@ func NewPublicRouter(app huma.API) { }) huma.Register(app, huma.Operation{ - OperationID: "tinyurl", + OperationID: "get-tinyurl-with-redirect", Method: http.MethodGet, Path: configs.GetShortURL, - Summary: "Get a original URL", - Description: "Get a original URL.", + Summary: "Redirect to original URL", Tags: []string{"Public"}, - }, func(ctx context.Context, query *struct { - GetTinyURLQuery - }) (*GetTinyURLResponse, error) { - resp := &GetTinyURLResponse{} - shortURL, err := h.GetShortURLHandler(ctx, query.ID) - if err != nil { - return nil, err - } - resp.Body.ID = shortURL.ID - resp.Body.OriginalURL = shortURL.OriginalURL - resp.Body.CreatedAt = shortURL.CreatedAt - return resp, nil + Parameters: []*huma.Param{ + { + Name: "id", + In: "path", + Description: "ID of the short URL", + Required: true, + Schema: &huma.Schema{ + Type: "string", + }, + }, + }, + Responses: map[string]*huma.Response{ + "301": { + Description: "Redirect to original URL", + Headers: map[string]*huma.Header{ + "Location": { + Description: "Location of the original URL", + Schema: &huma.Schema{ + Type: "string", + Format: "uri", + }, + }, + }, + }, + "404": { + Description: "Short URL not found", + Content: map[string]*huma.MediaType{ + "text/plain": { + Schema: &huma.Schema{ + Type: "string", + }, + }, + }, + }, + }, + }, func(ctx context.Context, input *GetInfoTinyURLQuery) (*GetTinyURLResponse, error) { + fmt.Printf("GetInfoTinyURLQuery: %v", input.ID) + logger.Info("parammm: %v", input.ID) + shortURL, err := h.GetShortURLHandler(ctx, input.ID) + logger.Info("Result err GetShortURLHandler: %v", err) // null + logger.Info("Result GetShortURLHandler: %v", shortURL.OriginalURL) // https://example.com/ + return &GetTinyURLResponse{ + Status: http.StatusTemporaryRedirect, + Url: shortURL.OriginalURL, + }, nil }) huma.Register(app, huma.Operation{ - OperationID: "tinyurl", + OperationID: "create-tinyurl", Method: http.MethodPost, Path: configs.CreateShortURL, Summary: "Create a short URL", @@ -113,4 +152,28 @@ func NewPublicRouter(app huma.API) { resp.Body.ID = shortURL.ID return resp, nil }) + + huma.Register(app, huma.Operation{ + OperationID: "info-tinyurl", + Method: http.MethodGet, + Path: configs.GetOnlyShortURL, + Summary: "Get Info tinyurl", + Description: "Get Info tinyurl", + Tags: []string{"Public"}, + }, func(ctx context.Context, query *struct { + GetInfoTinyURLQuery + }) (*GetInfoTinyURLResponse, error) { + resp := &GetInfoTinyURLResponse{} + shortURL, err := h.GetShortURLHandler(ctx, query.ID) + if err != nil { + return nil, err + } + resp.Body.ID = shortURL.ID + resp.Body.OriginalURL = shortURL.OriginalURL + resp.Body.CreatedAt = shortURL.CreatedAt + return resp, nil + }) + + return app + } diff --git a/backend/internal/rest/router/user.go b/backend/internal/rest/router/user.go new file mode 100644 index 0000000..7ef135b --- /dev/null +++ b/backend/internal/rest/router/user.go @@ -0,0 +1 @@ +package router diff --git a/backend/internal/usecase/admin_usecase.go b/backend/internal/usecase/admin_usecase.go new file mode 100644 index 0000000..aed2454 --- /dev/null +++ b/backend/internal/usecase/admin_usecase.go @@ -0,0 +1 @@ +package usecase diff --git a/backend/internal/usecase/shorturl.go b/backend/internal/usecase/guest_usecase.go similarity index 78% rename from backend/internal/usecase/shorturl.go rename to backend/internal/usecase/guest_usecase.go index c26011d..38f5052 100644 --- a/backend/internal/usecase/shorturl.go +++ b/backend/internal/usecase/guest_usecase.go @@ -4,7 +4,7 @@ import ( "context" "github.com/naohito-T/tinyurl/backend/domain" - "github.com/naohito-T/tinyurl/backend/internal/infrastructures/slog" + "github.com/naohito-T/tinyurl/backend/internal/infrastructure" ) type IShortURLRepo interface { @@ -17,20 +17,27 @@ type ShortURLUsecase struct { // はい、その通りです。IShortURLRepo インターフェースは、Get と Create という二つのメソッドを定義しており、このインターフェースを実装するどのクラスも、これらのメソッドを具体的に実装する必要があります。そして、ShortURLUsecase の中で shortURLRepo.Create(originalURL) を呼び出すことによって、このインターフェースを満たす具体的な実装に対して処理を委譲しています。 // ここでのポイントは、ShortURLUsecase クラスが IShortURLRepo インターフェースの具体的な実装に依存していないということです。この設計により、IShortURLRepo の実装を変更しても、ShortURLUsecase クラスを修正する必要がなくなります。つまり、データアクセス層の実装が変わっても、ビジネスロジック層は影響を受けないという設計原則(オープン/クローズド原則)に従っています。 shortURLRepo IShortURLRepo + logger infrastructure.ILogger } -func NewURLUsecase(u IShortURLRepo) *ShortURLUsecase { +func NewURLUsecase(u IShortURLRepo, logger infrastructure.ILogger) *ShortURLUsecase { return &ShortURLUsecase{ shortURLRepo: u, + logger: logger, } } func (u *ShortURLUsecase) GetByShortURL(ctx context.Context, hashID string) (domain.ShortURL, error) { - slog.NewLogger().Info("GetByShortURL: %v", hashID) + u.logger.Info("GetByShortURL: %v", hashID) return u.shortURLRepo.Get(ctx, hashID) } func (u *ShortURLUsecase) CreateShortURL(ctx context.Context, originalURL string) (domain.ShortURL, error) { - slog.NewLogger().Info("CreateShortURL: %v", originalURL) + u.logger.Info("CreateShortURL: %v", originalURL) return u.shortURLRepo.Put(ctx, domain.GenerateShortURL(originalURL)) } + +func (u *ShortURLUsecase) Search(ctx context.Context, originalURL string) (domain.ShortURL, error) { + u.logger.Info("GetByOriginalURL: %v", originalURL) + return u.shortURLRepo.Get(ctx, originalURL) +} diff --git a/backend/internal/usecase/login_usecase.go b/backend/internal/usecase/login_usecase.go new file mode 100644 index 0000000..aed2454 --- /dev/null +++ b/backend/internal/usecase/login_usecase.go @@ -0,0 +1 @@ +package usecase diff --git a/backend/schema/api/api_schema.go b/backend/schema/api/api_schema.go new file mode 100644 index 0000000..981da87 --- /dev/null +++ b/backend/schema/api/api_schema.go @@ -0,0 +1,46 @@ +package api + +import ( + "github.com/danielgtaylor/huma/v2" + "github.com/naohito-T/tinyurl/backend/configs" + + "sync" +) + +var onceInitHuma = sync.OnceValue(func() huma.Config { + config := huma.DefaultConfig(configs.OpenAPITitle, configs.OpenAPIVersion) + // /api/v1/openapi.yaml + config.Servers = []*huma.Server{ + {URL: configs.OpenAPIDocServerPath, Description: "Local API Server"}, + {URL: configs.OpenAPIDocServerPath, Description: "Dev API Server"}, + {URL: configs.OpenAPIDocServerPath, Description: "Prod API Server"}, + } + + config.Info = &huma.Info{ + Title: configs.OpenAPITitle, + Version: configs.OpenAPIVersion, + Description: "This is a simple URL shortener service.", + Contact: &huma.Contact{ + Name: "naohito-T", + Email: "naohito.tanaka0523@gmail.com", + URL: "https://naohito-t.github.io/", + }, + License: &huma.License{ + Name: "MIT", + URL: "https://opensource.org/licenses/MIT", + }, + } + config.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + "bearer": { + Type: "http", + Scheme: "bearer", + BearerFormat: "JWT", + }, + } + // config.DocsPath = "/docs" + return config +}) + +func NewHumaConfig() huma.Config { + return onceInitHuma() +} diff --git a/backend/schema/api/health_schema.go b/backend/schema/api/health_schema.go new file mode 100644 index 0000000..a859227 --- /dev/null +++ b/backend/schema/api/health_schema.go @@ -0,0 +1,62 @@ +package api + +import ( + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/naohito-T/tinyurl/backend/configs" +) + +func GetHealthAPISchema() *huma.Operation { + return &huma.Operation{ + OperationID: "health", + Method: http.MethodGet, + Path: configs.Health, + Summary: "Health Check", + Description: "Check the health of the service.", + Tags: []string{"Public"}, + Responses: map[string]*huma.Response{ + "200": { + Description: "Health check successful", + Content: map[string]*huma.MediaType{ + "application/json": { + Schema: &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "message": { + Type: "string", + }, + }, + }, + Example: "{message: ok}", + }, + }, + }, + "503": { + Description: "Service unavailable", + Content: map[string]*huma.MediaType{ + "application/problem+json": { + Schema: &huma.Schema{ + Type: "object", + Properties: map[string]*huma.Schema{ + "type": { + Type: "string", + Format: "uri", + }, + "title": { + Type: "string", + }, + "status": { + Type: "integer", + }, + "detail": { + Type: "string", + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/lefthook.yml b/lefthook.yml index ce1d5d8..0fb2da3 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -10,7 +10,9 @@ pre-push: tags: backend security root: "backend" glob: "*.{go}" - run: go mod tidy + run: | + go mod tidy + make build-openapi pre-commit: parallel: true