├── api ├── query.graphql ├── mutation.graphql ├── subscription.graphql ├── tagquery.graphql ├── imagequery.graphql ├── tagmutation.graphql ├── articlequery.graphql ├── projectquery.graphql ├── imagemutation.graphql ├── articlemutation.graphql ├── projectmutation.graphql ├── articleblockquery.graphql ├── articletagmutation.graphql ├── articleblocksubscription.graphql ├── articleblockmutation.graphql ├── _scalars.graphql ├── totalcount.graphql ├── problem.graphql ├── version.graphql ├── sortrank.graphql ├── articleblock.graphql ├── _directives.graphql ├── articlemutation_create.graphql ├── tag.graphql ├── articleblockhtml.graphql ├── project.graphql ├── tagmutation_update.graphql ├── articletag.graphql ├── tagmutation_create.graphql ├── projectmutation_update.graphql ├── projectmutation_create.graphql ├── articlemutation_update.graphql ├── articletagmutation_move.graphql ├── articleblockmutation_move.graphql ├── articleblockimage.graphql ├── articleblockmutation_create.graphql ├── imagemutation_upload.graphql ├── imagequery_find.graphql ├── tagquery_find.graphql ├── articlequery_find.graphql ├── articletagmutation_create.graphql ├── projectquery_find.graphql ├── articleblockmutation_update.graphql ├── articleblockquery_find.graphql ├── article.graphql └── image.graphql ├── .gitignore ├── .dockerignore ├── internal ├── app │ ├── context.go │ ├── logger.go │ ├── env.go │ ├── storages.go │ ├── services.go │ ├── repositories.go │ ├── db.go │ ├── dataloaders.go │ └── args.go ├── feature │ ├── dbmigration │ │ ├── entity.go │ │ └── repository.go │ ├── tag │ │ ├── entity.go │ │ ├── mapper.go │ │ ├── loaderbyid.go │ │ ├── repository.go │ │ ├── service.go │ │ └── loaderbyid_gen.go │ ├── project │ │ ├── entity.go │ │ ├── mapper.go │ │ ├── loaderbyid.go │ │ ├── repository.go │ │ ├── service.go │ │ └── loaderbyid_gen.go │ ├── article │ │ ├── entity.go │ │ ├── mapper.go │ │ ├── loaderbyid.go │ │ ├── repository.go │ │ ├── service.go │ │ └── loaderbyid_gen.go │ ├── articletag │ │ ├── entity.go │ │ ├── mapper.go │ │ ├── loaderbyarticleid.go │ │ ├── service.go │ │ ├── repository.go │ │ └── loaderbyarticleid_gen.go │ ├── image │ │ ├── entity.go │ │ ├── mapper.go │ │ ├── storage.go │ │ ├── loaderbyid.go │ │ ├── repository.go │ │ ├── service.go │ │ └── loaderbyid_gen.go │ └── articleblock │ │ ├── entity.go │ │ ├── mapper.go │ │ ├── mapper_image.go │ │ ├── mapper_html.go │ │ ├── loaderbyarticleid.go │ │ ├── repository.go │ │ └── service.go ├── gql │ ├── resolver │ │ ├── problem.go │ │ ├── resolver.go │ │ ├── articlequery_find_complexity.go │ │ ├── query.go │ │ ├── mutation.go │ │ ├── subscription.go │ │ ├── tagquery.go │ │ ├── imagequery.go │ │ ├── tagmutation.go │ │ ├── articlequery.go │ │ ├── projectquery.go │ │ ├── imagemutation.go │ │ ├── articlemutation.go │ │ ├── projectmutation.go │ │ ├── articleblockquery.go │ │ ├── articletagmutation.go │ │ ├── articleblockmutation.go │ │ ├── tagmutation_create.go │ │ ├── image.go │ │ ├── articlemutation_create.go │ │ ├── projectmutation_create.go │ │ ├── tagmutation_update.go │ │ ├── articlemutation_update.go │ │ ├── articletag.go │ │ ├── projectmutation_update.go │ │ ├── articleblockmutation_move.go │ │ ├── articletagmutation_move.go │ │ ├── articleblockmutation_create.go │ │ ├── imagemutation_upload.go │ │ ├── articletagmutation_create.go │ │ ├── articleblockimage.go │ │ ├── articleblockmutation_update.go │ │ ├── tagquery_find.go │ │ ├── articleblocksubscription.go │ │ ├── projectquery_find.go │ │ ├── articlequery_find.go │ │ ├── imagequery_find.go │ │ ├── articleblockquery_find.go │ │ └── article.go │ ├── model │ │ ├── html.go │ │ ├── uuid.go │ │ ├── rfc3339date.go │ │ ├── url.go │ │ ├── uint.go │ │ ├── pagesize.go │ │ └── pagenumber.go │ └── directive │ │ ├── inputunion.go │ │ └── sortrankinput.go ├── http │ ├── handler │ │ ├── app.go │ │ └── graphql.go │ └── middleware │ │ └── dataloaders_injector.go ├── go-pg │ └── condition │ │ └── condition.go ├── file │ └── image.go └── migration │ └── args.go ├── README.md ├── .gqlgen.yml ├── LICENSE ├── go.mod ├── Makefile ├── cmd ├── app │ └── main.go └── migration │ └── main.go └── migrations └── 0001.sql /api/query.graphql: -------------------------------------------------------------------------------- 1 | type Query -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | -------------------------------------------------------------------------------- /api/mutation.graphql: -------------------------------------------------------------------------------- 1 | type Mutation -------------------------------------------------------------------------------- /api/subscription.graphql: -------------------------------------------------------------------------------- 1 | type Subscription -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | migrations 3 | !.gqlgen.yml 4 | Dockerfile 5 | LICENSE 6 | README.md -------------------------------------------------------------------------------- /api/tagquery.graphql: -------------------------------------------------------------------------------- 1 | type TagQuery 2 | 3 | extend type Query { 4 | tag: TagQuery! 5 | } -------------------------------------------------------------------------------- /api/imagequery.graphql: -------------------------------------------------------------------------------- 1 | type ImageQuery 2 | 3 | extend type Query { 4 | image: ImageQuery! 5 | } -------------------------------------------------------------------------------- /api/tagmutation.graphql: -------------------------------------------------------------------------------- 1 | type TagMutation 2 | 3 | extend type Mutation { 4 | tag: TagMutation! 5 | } -------------------------------------------------------------------------------- /api/articlequery.graphql: -------------------------------------------------------------------------------- 1 | type ArticleQuery 2 | 3 | extend type Query { 4 | article: ArticleQuery! 5 | } -------------------------------------------------------------------------------- /api/projectquery.graphql: -------------------------------------------------------------------------------- 1 | type ProjectQuery 2 | 3 | extend type Query { 4 | project: ProjectQuery! 5 | } -------------------------------------------------------------------------------- /api/imagemutation.graphql: -------------------------------------------------------------------------------- 1 | type ImageMutation 2 | 3 | extend type Mutation { 4 | image: ImageMutation! 5 | } -------------------------------------------------------------------------------- /api/articlemutation.graphql: -------------------------------------------------------------------------------- 1 | type ArticleMutation 2 | 3 | extend type Mutation { 4 | article: ArticleMutation! 5 | } -------------------------------------------------------------------------------- /api/projectmutation.graphql: -------------------------------------------------------------------------------- 1 | type ProjectMutation 2 | 3 | extend type Mutation { 4 | project: ProjectMutation! 5 | } -------------------------------------------------------------------------------- /api/articleblockquery.graphql: -------------------------------------------------------------------------------- 1 | type ArticleBlockQuery 2 | 3 | extend type Query { 4 | articleBlock: ArticleBlockQuery! 5 | } -------------------------------------------------------------------------------- /api/articletagmutation.graphql: -------------------------------------------------------------------------------- 1 | type ArticleTagMutation 2 | 3 | extend type Mutation { 4 | articleTag: ArticleTagMutation! 5 | } -------------------------------------------------------------------------------- /api/articleblocksubscription.graphql: -------------------------------------------------------------------------------- 1 | extend type Subscription { 2 | articleBlockCreated(articleId: Uuid!): ArticleBlockInterface! 3 | } -------------------------------------------------------------------------------- /internal/app/context.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | type ContextKey int 4 | 5 | const ( 6 | DataLoadersContextKey ContextKey = iota 7 | ) 8 | -------------------------------------------------------------------------------- /api/articleblockmutation.graphql: -------------------------------------------------------------------------------- 1 | type ArticleBlockMutation 2 | 3 | extend type Mutation { 4 | articleBlock: ArticleBlockMutation! 5 | } -------------------------------------------------------------------------------- /api/_scalars.graphql: -------------------------------------------------------------------------------- 1 | scalar Html 2 | scalar PageNumber 3 | scalar PageSize 4 | scalar Rfc3339Date 5 | scalar UInt 6 | scalar Upload 7 | scalar Url 8 | scalar Uuid -------------------------------------------------------------------------------- /api/totalcount.graphql: -------------------------------------------------------------------------------- 1 | type TotalCount { 2 | value: UInt! 3 | } 4 | 5 | union TotalCountResolvingResult = 6 | | TotalCount 7 | | InternalErrorProblem -------------------------------------------------------------------------------- /api/problem.graphql: -------------------------------------------------------------------------------- 1 | interface ProblemInterface { 2 | message: String! 3 | } 4 | 5 | type InternalErrorProblem implements ProblemInterface { 6 | message: String! 7 | } -------------------------------------------------------------------------------- /api/version.graphql: -------------------------------------------------------------------------------- 1 | interface VersionInterface { 2 | version: UInt! 3 | } 4 | 5 | type VersionMismatchProblem implements ProblemInterface { 6 | message: String! 7 | } -------------------------------------------------------------------------------- /api/sortrank.graphql: -------------------------------------------------------------------------------- 1 | input SortRankInput { 2 | prev: String! = "0" 3 | next: String! = "z" 4 | } 5 | 6 | type InvalidSortRankProblem implements ProblemInterface { 7 | message: String! 8 | } 9 | -------------------------------------------------------------------------------- /internal/feature/dbmigration/entity.go: -------------------------------------------------------------------------------- 1 | package dbmigration 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type DBMigration struct { 8 | tableName struct{} `pg:"_db_migration"` 9 | 10 | AppliedAt *time.Time `pg:",use_zero"` 11 | Name string `pg:",pk"` 12 | } 13 | -------------------------------------------------------------------------------- /api/articleblock.graphql: -------------------------------------------------------------------------------- 1 | interface ArticleBlockInterface { 2 | createdAt: Rfc3339Date! 3 | deletedAt: Rfc3339Date 4 | id: Uuid! 5 | modifiedAt: Rfc3339Date! 6 | sortRank: String! 7 | } 8 | 9 | type ArticleBlockNotFoundProblem implements ProblemInterface { 10 | message: String! 11 | } 12 | 13 | enum ArticleBlockTypeEnum { 14 | HTML 15 | IMAGE 16 | } -------------------------------------------------------------------------------- /api/_directives.graphql: -------------------------------------------------------------------------------- 1 | directive @goModel( 2 | model: String 3 | models: [String!] 4 | ) on OBJECT | INPUT_OBJECT | SCALAR | ENUM | INTERFACE | UNION 5 | 6 | directive @goField( 7 | forceResolver: Boolean 8 | name: String 9 | ) on INPUT_FIELD_DEFINITION | FIELD_DEFINITION 10 | 11 | directive @inputUnion on INPUT_FIELD_DEFINITION 12 | 13 | directive @sortRankInput on INPUT_FIELD_DEFINITION -------------------------------------------------------------------------------- /internal/gql/resolver/problem.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import "github.com/acelot/articles/internal/gql/model" 4 | 5 | func NewInternalErrorProblem() model.InternalErrorProblem { 6 | return model.InternalErrorProblem{Message: "internal server error"} 7 | } 8 | 9 | func NewVersionMismatchProblem() model.VersionMismatchProblem { 10 | return model.VersionMismatchProblem{Message: "version mismatch"} 11 | } 12 | -------------------------------------------------------------------------------- /api/articlemutation_create.graphql: -------------------------------------------------------------------------------- 1 | extend type ArticleMutation { 2 | create(input: ArticleCreateInput!): ArticleCreateResult! @goField(forceResolver: true) 3 | } 4 | 5 | input ArticleCreateInput { 6 | projectId: Uuid! 7 | } 8 | 9 | type ArticleCreateOk { 10 | article: Article! 11 | } 12 | 13 | union ArticleCreateResult = 14 | | ProjectNotFoundProblem 15 | | InternalErrorProblem 16 | | ArticleCreateOk -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-articles 2 | Articles GraphQL Service 3 | 4 | Requierements: 5 | - libvips 6 | 7 | ## How to build 8 | 9 | ```shell 10 | make build 11 | ``` 12 | 13 | ## How to apply DB migrations 14 | 15 | ```shell 16 | migration update postgres://user:pass@localhost/articles\?sslmode=disable 17 | ``` 18 | 19 | ## How to run app 20 | 21 | ```shell 22 | app postgres://user:pass@localhost/articles\?sslmode=disable 23 | ``` -------------------------------------------------------------------------------- /api/tag.graphql: -------------------------------------------------------------------------------- 1 | type Tag implements VersionInterface { 2 | createdAt: Rfc3339Date! 3 | deletedAt: Rfc3339Date 4 | id: Uuid! 5 | modifiedAt: Rfc3339Date! 6 | name: String! 7 | version: UInt! 8 | } 9 | 10 | type TagNotFoundProblem implements ProblemInterface { 11 | message: String! 12 | } 13 | 14 | union TagResolvingResult = 15 | | Tag 16 | | TagNotFoundProblem 17 | | InternalErrorProblem 18 | -------------------------------------------------------------------------------- /api/articleblockhtml.graphql: -------------------------------------------------------------------------------- 1 | type ArticleBlockHtml implements ArticleBlockInterface & VersionInterface { 2 | createdAt: Rfc3339Date! 3 | data: ArticleBlockHtmlData! 4 | deletedAt: Rfc3339Date 5 | id: Uuid! 6 | modifiedAt: Rfc3339Date! 7 | sortRank: String! 8 | version: UInt! 9 | } 10 | 11 | type ArticleBlockHtmlData { 12 | body: Html! 13 | } 14 | 15 | input ArticleBlockHtmlDataInput { 16 | body: Html! 17 | } -------------------------------------------------------------------------------- /internal/gql/resolver/resolver.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/app" 5 | ) 6 | 7 | // This file will not be regenerated automatically. 8 | // 9 | // It serves as dependency injection for your app, add any dependencies you require here. 10 | 11 | type Resolver struct{ 12 | env *app.Env 13 | } 14 | 15 | func NewResolver(env *app.Env) *Resolver { 16 | return &Resolver{ 17 | env, 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /internal/http/handler/app.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/app" 5 | "github.com/acelot/articles/internal/http/middleware" 6 | "net/http" 7 | ) 8 | 9 | func NewAppHandler(env *app.Env) *http.ServeMux { 10 | dataLoaderInjector := middleware.NewDataLoadersInjector(env) 11 | 12 | mux := http.NewServeMux() 13 | mux.Handle("/api", dataLoaderInjector(NewGraphQLHandler(env))) 14 | 15 | return mux 16 | } 17 | -------------------------------------------------------------------------------- /api/project.graphql: -------------------------------------------------------------------------------- 1 | type Project implements VersionInterface { 2 | createdAt: Rfc3339Date! 3 | deletedAt: Rfc3339Date 4 | id: Uuid! 5 | modifiedAt: Rfc3339Date! 6 | name: String! 7 | version: UInt! 8 | } 9 | 10 | type ProjectNotFoundProblem implements ProblemInterface { 11 | message: String! 12 | } 13 | 14 | union ProjectResolvingResult = 15 | | Project 16 | | ProjectNotFoundProblem 17 | | InternalErrorProblem 18 | -------------------------------------------------------------------------------- /api/tagmutation_update.graphql: -------------------------------------------------------------------------------- 1 | extend type TagMutation { 2 | update(input: TagUpdateInput!): TagUpdateResult! @goField(forceResolver: true) 3 | } 4 | 5 | input TagUpdateInput { 6 | id: Uuid! 7 | name: String! 8 | version: UInt! 9 | } 10 | 11 | type TagUpdateOk { 12 | tag: Tag! 13 | } 14 | 15 | union TagUpdateResult = 16 | | TagNotFoundProblem 17 | | VersionMismatchProblem 18 | | InternalErrorProblem 19 | | TagUpdateOk 20 | -------------------------------------------------------------------------------- /internal/feature/tag/entity.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "time" 6 | ) 7 | 8 | type Tag struct { 9 | tableName struct{} `pg:"tag"` 10 | 11 | CreatedAt time.Time `pg:",notnull,use_zero"` 12 | DeletedAt *time.Time 13 | ID uuid.UUID `pg:"type:uuid,pk"` 14 | ModifiedAt time.Time `pg:",notnull,use_zero"` 15 | Name string `pg:",notnull,use_zero"` 16 | Version uint `pg:",notnull,use_zero"` 17 | } 18 | -------------------------------------------------------------------------------- /api/articletag.graphql: -------------------------------------------------------------------------------- 1 | type ArticleTag implements VersionInterface { 2 | articleID: Uuid! 3 | createdAt: Rfc3339Date! 4 | id: Uuid! 5 | modifiedAt: Rfc3339Date! 6 | sortRank: String! 7 | tag: TagResolvingResult! @goField(forceResolver: true) 8 | tagId: Uuid! 9 | version: UInt! 10 | } 11 | 12 | type ArticleTagList { 13 | items: [ArticleTag!]! 14 | } 15 | 16 | type ArticleTagNotFoundProblem implements ProblemInterface { 17 | message: String! 18 | } -------------------------------------------------------------------------------- /api/tagmutation_create.graphql: -------------------------------------------------------------------------------- 1 | extend type TagMutation { 2 | create(input: TagCreateInput!): TagCreateResult! @goField(forceResolver: true) 3 | } 4 | 5 | input TagCreateInput { 6 | name: String! 7 | } 8 | 9 | type TagAlreadyExistsProblem implements ProblemInterface { 10 | message: String! 11 | } 12 | 13 | type TagCreateOk { 14 | tag: Tag! 15 | } 16 | 17 | union TagCreateResult = 18 | | TagAlreadyExistsProblem 19 | | InternalErrorProblem 20 | | TagCreateOk -------------------------------------------------------------------------------- /internal/feature/project/entity.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "time" 6 | ) 7 | 8 | type Project struct { 9 | tableName struct{} `pg:"project"` 10 | 11 | CreatedAt time.Time `pg:",notnull,use_zero"` 12 | DeletedAt *time.Time 13 | ID uuid.UUID `pg:"type:uuid,pk"` 14 | ModifiedAt time.Time `pg:",notnull,use_zero"` 15 | Name string `pg:",notnull,use_zero"` 16 | Version uint `pg:",notnull,use_zero"` 17 | } 18 | -------------------------------------------------------------------------------- /internal/gql/resolver/articlequery_find_complexity.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | import "github.com/acelot/articles/internal/gql/model" 4 | 5 | func ArticleQueryFindComplexity(childComplexity int, filter *model.ArticleFindFilterInput, sort model.ArticleFindSortEnum, pageSize uint, pageNumber uint) int { 6 | return 10 + int(pageSize)*childComplexity 7 | } 8 | 9 | func ArticleFindListTotalCountComplexity(childComplexity int, estimate uint) int { 10 | return 10 + childComplexity 11 | } 12 | -------------------------------------------------------------------------------- /api/projectmutation_update.graphql: -------------------------------------------------------------------------------- 1 | extend type ProjectMutation { 2 | update(input: ProjectUpdateInput!): ProjectUpdateResult! @goField(forceResolver: true) 3 | } 4 | 5 | input ProjectUpdateInput { 6 | id: Uuid! 7 | name: String! 8 | version: UInt! 9 | } 10 | 11 | type ProjectUpdateOk { 12 | project: Project! 13 | } 14 | 15 | union ProjectUpdateResult = 16 | | ProjectNotFoundProblem 17 | | VersionMismatchProblem 18 | | InternalErrorProblem 19 | | ProjectUpdateOk 20 | -------------------------------------------------------------------------------- /internal/gql/resolver/query.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "github.com/acelot/articles/internal/gql/runtime" 8 | ) 9 | 10 | // Query returns runtime.QueryResolver implementation. 11 | func (r *Resolver) Query() runtime.QueryResolver { return &queryResolver{r} } 12 | 13 | type queryResolver struct{ *Resolver } 14 | -------------------------------------------------------------------------------- /api/projectmutation_create.graphql: -------------------------------------------------------------------------------- 1 | extend type ProjectMutation { 2 | create(input: ProjectCreateInput!): ProjectCreateResult! @goField(forceResolver: true) 3 | } 4 | 5 | input ProjectCreateInput { 6 | name: String! 7 | } 8 | 9 | type ProjectAlreadyExistsProblem implements ProblemInterface { 10 | message: String! 11 | } 12 | 13 | type ProjectCreateOk { 14 | project: Project! 15 | } 16 | 17 | union ProjectCreateResult = 18 | | ProjectAlreadyExistsProblem 19 | | InternalErrorProblem 20 | | ProjectCreateOk -------------------------------------------------------------------------------- /internal/gql/resolver/mutation.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "github.com/acelot/articles/internal/gql/runtime" 8 | ) 9 | 10 | // Mutation returns runtime.MutationResolver implementation. 11 | func (r *Resolver) Mutation() runtime.MutationResolver { return &mutationResolver{r} } 12 | 13 | type mutationResolver struct{ *Resolver } 14 | -------------------------------------------------------------------------------- /api/articlemutation_update.graphql: -------------------------------------------------------------------------------- 1 | extend type ArticleMutation { 2 | update( 3 | input: ArticleUpdateInput! 4 | ): ArticleUpdateResult! @goField(forceResolver: true) 5 | } 6 | 7 | input ArticleUpdateInput { 8 | id: Uuid! 9 | coverImageId: Uuid 10 | title: String! 11 | version: UInt! 12 | } 13 | 14 | type ArticleUpdateOk { 15 | article: Article! 16 | } 17 | 18 | union ArticleUpdateResult = 19 | | ArticleNotFoundProblem 20 | | VersionMismatchProblem 21 | | InternalErrorProblem 22 | | ArticleUpdateOk -------------------------------------------------------------------------------- /internal/gql/model/html.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/99designs/gqlgen/graphql" 6 | "io" 7 | ) 8 | 9 | type Html string 10 | 11 | func MarshalHtml(h string) graphql.Marshaler { 12 | return graphql.WriterFunc(func(w io.Writer) { 13 | io.WriteString(w, fmt.Sprintf(`"%s"`, h)) 14 | }) 15 | } 16 | 17 | func UnmarshalHtml(v interface{}) (string, error) { 18 | value, err := graphql.UnmarshalString(v) 19 | if err != nil { 20 | return "", err 21 | } 22 | 23 | // @TODO Validate HTML 24 | return value, nil 25 | } 26 | -------------------------------------------------------------------------------- /internal/feature/article/entity.go: -------------------------------------------------------------------------------- 1 | package article 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "time" 6 | ) 7 | 8 | type Article struct { 9 | tableName struct{} `pg:"article"` 10 | 11 | CoverImageID *uuid.UUID `pg:"type:uuid"` 12 | CreatedAt time.Time `pg:",use_zero"` 13 | DeletedAt *time.Time 14 | ID uuid.UUID `pg:"type:uuid,pk"` 15 | ModifiedAt time.Time `pg:",use_zero"` 16 | ProjectID uuid.UUID `pg:"type:uuid"` 17 | Title string `pg:",use_zero"` 18 | Version uint `pg:",use_zero"` 19 | } 20 | -------------------------------------------------------------------------------- /internal/feature/articletag/entity.go: -------------------------------------------------------------------------------- 1 | package articletag 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "time" 6 | ) 7 | 8 | type ArticleTag struct { 9 | tableName struct{} `pg:"article_tag"` 10 | 11 | ArticleID uuid.UUID `pg:"type:uuid,notnull"` 12 | CreatedAt time.Time `pg:",notnull,use_zero"` 13 | ID uuid.UUID `pg:"type:uuid,pk"` 14 | ModifiedAt time.Time `pg:",notnull,use_zero"` 15 | SortRank string `pg:",notnull,use_zero"` 16 | TagID uuid.UUID `pg:"type:uuid,notnull"` 17 | Version uint `pg:",notnull,use_zero"` 18 | } 19 | -------------------------------------------------------------------------------- /internal/gql/resolver/subscription.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "github.com/acelot/articles/internal/gql/runtime" 8 | ) 9 | 10 | // Subscription returns runtime.SubscriptionResolver implementation. 11 | func (r *Resolver) Subscription() runtime.SubscriptionResolver { return &subscriptionResolver{r} } 12 | 13 | type subscriptionResolver struct{ *Resolver } 14 | -------------------------------------------------------------------------------- /internal/feature/image/entity.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "time" 6 | ) 7 | 8 | type Image struct { 9 | tableName struct{} `pg:"image"` 10 | 11 | CreatedAt time.Time `pg:",notnull,use_zero"` 12 | DeletedAt *time.Time 13 | Type string `pg:",notnull,use_zero"` 14 | Height int `pg:",notnull,use_zero"` 15 | ID uuid.UUID `pg:"type:uuid,pk"` 16 | ModifiedAt time.Time `pg:",notnull,use_zero"` 17 | Version uint `pg:",notnull,use_zero"` 18 | Width int `pg:",notnull,use_zero"` 19 | } 20 | -------------------------------------------------------------------------------- /api/articletagmutation_move.graphql: -------------------------------------------------------------------------------- 1 | extend type ArticleTagMutation { 2 | move(input: ArticleTagMoveInput!): ArticleTagMoveResult! @goField(forceResolver: true) 3 | } 4 | 5 | input ArticleTagMoveInput { 6 | id: Uuid! 7 | sortRank: SortRankInput! @sortRankInput 8 | version: UInt! 9 | } 10 | 11 | type ArticleTagMoveOk { 12 | sortRank: String! 13 | version: UInt! 14 | } 15 | 16 | union ArticleTagMoveResult = 17 | | ArticleTagNotFoundProblem 18 | | InvalidSortRankProblem 19 | | VersionMismatchProblem 20 | | InternalErrorProblem 21 | | ArticleTagMoveOk -------------------------------------------------------------------------------- /internal/gql/model/uuid.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/99designs/gqlgen/graphql" 6 | "github.com/google/uuid" 7 | "io" 8 | ) 9 | 10 | type Uuid uuid.UUID 11 | 12 | func MarshalUuid(u uuid.UUID) graphql.Marshaler { 13 | return graphql.WriterFunc(func(w io.Writer) { 14 | io.WriteString(w, fmt.Sprintf(`"%s"`, u.String())) 15 | }) 16 | } 17 | 18 | func UnmarshalUuid(v interface{}) (uuid.UUID, error) { 19 | value, err := graphql.UnmarshalString(v) 20 | if err != nil { 21 | return uuid.Nil, err 22 | } 23 | 24 | return uuid.Parse(value) 25 | } 26 | -------------------------------------------------------------------------------- /api/articleblockmutation_move.graphql: -------------------------------------------------------------------------------- 1 | extend type ArticleBlockMutation { 2 | move(input: ArticleBlockMoveInput!): ArticleBlockMoveResult! @goField(forceResolver: true) 3 | } 4 | 5 | input ArticleBlockMoveInput { 6 | id: Uuid! 7 | sortRank: SortRankInput! @sortRankInput 8 | version: UInt! 9 | } 10 | 11 | type ArticleBlockMoveOk { 12 | sortRank: String! 13 | version: UInt! 14 | } 15 | 16 | union ArticleBlockMoveResult = 17 | | ArticleBlockNotFoundProblem 18 | | InvalidSortRankProblem 19 | | VersionMismatchProblem 20 | | InternalErrorProblem 21 | | ArticleBlockMoveOk -------------------------------------------------------------------------------- /internal/http/middleware/dataloaders_injector.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "context" 5 | "github.com/acelot/articles/internal/app" 6 | "net/http" 7 | ) 8 | 9 | func NewDataLoadersInjector(env *app.Env) func(next http.Handler) http.Handler { 10 | return func(next http.Handler) http.Handler { 11 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 12 | ctx := context.WithValue( 13 | r.Context(), 14 | app.DataLoadersContextKey, 15 | app.NewDataLoaders(env.Repositories), 16 | ) 17 | 18 | next.ServeHTTP(w, r.WithContext(ctx)) 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /api/articleblockimage.graphql: -------------------------------------------------------------------------------- 1 | type ArticleBlockImage implements ArticleBlockInterface & VersionInterface { 2 | createdAt: Rfc3339Date! 3 | data: ArticleBlockImageData! 4 | deletedAt: Rfc3339Date 5 | id: Uuid! 6 | modifiedAt: Rfc3339Date! 7 | sortRank: String! 8 | version: UInt! 9 | } 10 | 11 | type ArticleBlockImageData { 12 | image: ImageResolvingResult! @goField(forceResolver: true) 13 | imageId: Uuid 14 | copyright: String 15 | description: Html 16 | } 17 | 18 | input ArticleBlockImageDataInput { 19 | imageId: Uuid 20 | copyright: String 21 | description: Html 22 | } -------------------------------------------------------------------------------- /api/articleblockmutation_create.graphql: -------------------------------------------------------------------------------- 1 | extend type ArticleBlockMutation { 2 | create( 3 | input: ArticleBlockCreateInput! 4 | ): ArticleBlockCreateResult! @goField(forceResolver: true) 5 | } 6 | 7 | input ArticleBlockCreateInput { 8 | articleId: Uuid! 9 | blockType: ArticleBlockTypeEnum! 10 | sortRank: SortRankInput! @sortRankInput 11 | } 12 | 13 | type ArticleBlockCreateOk { 14 | articleBlock: ArticleBlockInterface! 15 | } 16 | 17 | union ArticleBlockCreateResult = 18 | | ArticleNotFoundProblem 19 | | InvalidSortRankProblem 20 | | InternalErrorProblem 21 | | ArticleBlockCreateOk 22 | -------------------------------------------------------------------------------- /internal/feature/tag/mapper.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/gql/model" 5 | ) 6 | 7 | func MapOneToGqlModel(tag Tag) *model.Tag { 8 | return &model.Tag{ 9 | CreatedAt: tag.CreatedAt, 10 | DeletedAt: tag.DeletedAt, 11 | ID: tag.ID, 12 | ModifiedAt: tag.ModifiedAt, 13 | Name: tag.Name, 14 | Version: tag.Version, 15 | } 16 | } 17 | 18 | func MapManyToGqlModels(projects []Tag) []*model.Tag { 19 | items := make([]*model.Tag, len(projects)) 20 | 21 | for i, entity := range projects { 22 | items[i] = MapOneToGqlModel(entity) 23 | } 24 | 25 | return items 26 | } -------------------------------------------------------------------------------- /internal/gql/model/rfc3339date.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/99designs/gqlgen/graphql" 6 | "io" 7 | "time" 8 | ) 9 | 10 | type Rfc3339Date time.Time 11 | 12 | func MarshalRfc3339Date(t time.Time) graphql.Marshaler { 13 | return graphql.WriterFunc(func(w io.Writer) { 14 | io.WriteString(w, fmt.Sprintf(`"%s"`, t.Format(time.RFC3339Nano))) 15 | }) 16 | } 17 | 18 | func UnmarshalRfc3339Date(v interface{}) (time.Time, error) { 19 | value, err := graphql.UnmarshalString(v) 20 | if err != nil { 21 | return time.Time{}, err 22 | } 23 | 24 | return time.Parse(time.RFC3339Nano, value) 25 | } 26 | -------------------------------------------------------------------------------- /api/imagemutation_upload.graphql: -------------------------------------------------------------------------------- 1 | extend type ImageMutation { 2 | upload(input: ImageUploadInput!): ImageUploadResult! @goField(forceResolver: true) 3 | } 4 | 5 | input ImageUploadInput { 6 | file: Upload 7 | } 8 | 9 | type ImageNotSupportedTypeProblem implements ProblemInterface { 10 | message: String! 11 | } 12 | 13 | type ImageNotRecognizedProblem implements ProblemInterface { 14 | message: String! 15 | } 16 | 17 | type ImageUploadOk { 18 | image: Image! 19 | } 20 | 21 | union ImageUploadResult = 22 | | ImageNotSupportedTypeProblem 23 | | ImageNotRecognizedProblem 24 | | InternalErrorProblem 25 | | ImageUploadOk 26 | -------------------------------------------------------------------------------- /internal/go-pg/condition/condition.go: -------------------------------------------------------------------------------- 1 | package condition 2 | 3 | import ( 4 | "github.com/go-pg/pg/v10" 5 | "go/types" 6 | ) 7 | 8 | type Condition interface { 9 | Apply(query *pg.Query) 10 | } 11 | 12 | func Apply(query *pg.Query, conditions ...Condition) { 13 | for _, c := range conditions { 14 | c.Apply(query) 15 | } 16 | } 17 | 18 | type Limit uint 19 | 20 | func (limit Limit) Apply(query *pg.Query) { 21 | query.Limit(int(limit)) 22 | } 23 | 24 | type Offset uint 25 | 26 | func (offset Offset) Apply(query *pg.Query) { 27 | query.Offset(int(offset)) 28 | } 29 | 30 | type None types.Nil 31 | 32 | func (none None) Apply(_ *pg.Query) {} 33 | -------------------------------------------------------------------------------- /internal/file/image.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "errors" 5 | "github.com/h2non/bimg" 6 | ) 7 | 8 | type ImageMeta struct { 9 | Type string 10 | Width int 11 | Height int 12 | } 13 | 14 | func GetImageMeta(buffer []byte) (*ImageMeta, error) { 15 | image := bimg.NewImage(buffer) 16 | 17 | imageType := image.Type() 18 | if imageType == "unknown" { 19 | return nil, errors.New("cannot recognize image type") 20 | } 21 | 22 | imageSize, err := image.Size() 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return &ImageMeta{ 28 | Type: imageType, 29 | Width: imageSize.Width, 30 | Height: imageSize.Height, 31 | }, nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/gql/model/url.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/99designs/gqlgen/graphql" 6 | "io" 7 | "net/url" 8 | ) 9 | 10 | type Url url.URL 11 | 12 | func MarshalUrl(u url.URL) graphql.Marshaler { 13 | return graphql.WriterFunc(func(w io.Writer) { 14 | io.WriteString(w, fmt.Sprintf(`"%s"`, u.String())) 15 | }) 16 | } 17 | 18 | func UnmarshalUrl(v interface{}) (url.URL, error) { 19 | value, err := graphql.UnmarshalString(v) 20 | if err != nil { 21 | return url.URL{}, err 22 | } 23 | 24 | parsedURL, err := url.ParseRequestURI(value) 25 | if err != nil { 26 | return url.URL{}, err 27 | } 28 | 29 | return *parsedURL, nil 30 | } 31 | -------------------------------------------------------------------------------- /internal/feature/project/mapper.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/gql/model" 5 | ) 6 | 7 | func MapOneToGqlModel(project Project) *model.Project { 8 | return &model.Project{ 9 | CreatedAt: project.CreatedAt, 10 | DeletedAt: project.DeletedAt, 11 | ID: project.ID, 12 | ModifiedAt: project.ModifiedAt, 13 | Name: project.Name, 14 | Version: project.Version, 15 | } 16 | } 17 | 18 | func MapManyToGqlModels(projects []Project) []*model.Project { 19 | items := make([]*model.Project, len(projects)) 20 | 21 | for i, entity := range projects { 22 | items[i] = MapOneToGqlModel(entity) 23 | } 24 | 25 | return items 26 | } -------------------------------------------------------------------------------- /api/imagequery_find.graphql: -------------------------------------------------------------------------------- 1 | extend type ImageQuery { 2 | find( 3 | filter: ImageFindFilterInput 4 | sort: ImageFindSortEnum! = CREATED_AT_ASC 5 | pageSize: PageSize! = 10 6 | pageNumber: PageNumber! = 1 7 | ): ImageFindResult! @goField(forceResolver: true) 8 | } 9 | 10 | enum ImageFindSortEnum { 11 | CREATED_AT_ASC 12 | CREATED_AT_DESC 13 | } 14 | 15 | input ImageFindFilterInput { 16 | idAnyOf: [Uuid!] 17 | } 18 | 19 | type ImageFindList { 20 | items: [Image!]! 21 | totalCount(estimate: UInt! = 10000): TotalCountResolvingResult! @goField(forceResolver: true) 22 | } 23 | 24 | union ImageFindResult = 25 | | ImageFindList 26 | | InternalErrorProblem -------------------------------------------------------------------------------- /api/tagquery_find.graphql: -------------------------------------------------------------------------------- 1 | extend type TagQuery { 2 | find( 3 | filter: TagFindFilterInput 4 | sort: TagFindSortEnum! = NAME_ASC 5 | pageSize: PageSize! = 10 6 | pageNumber: PageNumber! = 1 7 | ): TagFindResult! @goField(forceResolver: true) 8 | } 9 | 10 | enum TagFindSortEnum { 11 | NAME_ASC 12 | NAME_DESC 13 | CREATED_AT_ASC 14 | CREATED_AT_DESC 15 | } 16 | 17 | input TagFindFilterInput { 18 | idAnyOf: [Uuid!] 19 | } 20 | 21 | type TagFindList { 22 | items: [Tag!]! 23 | totalCount(estimate: UInt! = 10000): TotalCountResolvingResult! @goField(forceResolver: true) 24 | } 25 | 26 | union TagFindResult = 27 | | TagFindList 28 | | InternalErrorProblem -------------------------------------------------------------------------------- /api/articlequery_find.graphql: -------------------------------------------------------------------------------- 1 | extend type ArticleQuery { 2 | find( 3 | filter: ArticleFindFilterInput 4 | sort: ArticleFindSortEnum! = CREATED_AT_ASC 5 | pageSize: PageSize! = 10 6 | pageNumber: PageNumber! = 1 7 | ): ArticleFindResult! @goField(forceResolver: true) 8 | } 9 | 10 | enum ArticleFindSortEnum { 11 | CREATED_AT_ASC 12 | CREATED_AT_DESC 13 | } 14 | 15 | input ArticleFindFilterInput { 16 | idAnyOf: [Uuid!] 17 | } 18 | 19 | type ArticleFindList { 20 | items: [Article!]! 21 | totalCount(estimate: UInt! = 10000): TotalCountResolvingResult! @goField(forceResolver: true) 22 | } 23 | 24 | union ArticleFindResult = 25 | | ArticleFindList 26 | | InternalErrorProblem -------------------------------------------------------------------------------- /api/articletagmutation_create.graphql: -------------------------------------------------------------------------------- 1 | extend type ArticleTagMutation { 2 | create(input: ArticleTagCreateInput!): ArticleTagCreateResult! @goField(forceResolver: true) 3 | } 4 | 5 | input ArticleTagCreateInput { 6 | articleId: Uuid! 7 | tagId: Uuid! 8 | sortRank: SortRankInput! @sortRankInput 9 | } 10 | 11 | type ArticleTagAlreadyExistsProblem implements ProblemInterface { 12 | message: String! 13 | } 14 | 15 | type ArticleTagCreateOk { 16 | articleTag: ArticleTag! 17 | } 18 | 19 | union ArticleTagCreateResult = 20 | | ArticleNotFoundProblem 21 | | TagNotFoundProblem 22 | | ArticleTagAlreadyExistsProblem 23 | | InvalidSortRankProblem 24 | | InternalErrorProblem 25 | | ArticleTagCreateOk -------------------------------------------------------------------------------- /api/projectquery_find.graphql: -------------------------------------------------------------------------------- 1 | extend type ProjectQuery { 2 | find( 3 | filter: ProjectFindFilterInput 4 | sort: ProjectFindSortEnum! = NAME_ASC 5 | pageSize: PageSize! = 10 6 | pageNumber: PageNumber! = 1 7 | ): ProjectFindResult! @goField(forceResolver: true) 8 | } 9 | 10 | enum ProjectFindSortEnum { 11 | NAME_ASC 12 | NAME_DESC 13 | CREATED_AT_ASC 14 | CREATED_AT_DESC 15 | } 16 | 17 | input ProjectFindFilterInput { 18 | idAnyOf: [Uuid!] 19 | } 20 | 21 | type ProjectFindList { 22 | items: [Project!]! 23 | totalCount: TotalCountResolvingResult! @goField(forceResolver: true) 24 | } 25 | 26 | union ProjectFindResult = 27 | | ProjectFindList 28 | | InternalErrorProblem -------------------------------------------------------------------------------- /internal/app/logger.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | "go.uber.org/zap/zapcore" 6 | ) 7 | 8 | func NewLogger(level string) (*zap.Logger, error) { 9 | zapLevel := zapcore.DebugLevel 10 | if err := zapLevel.UnmarshalText([]byte(level)); err != nil { 11 | return nil, err 12 | } 13 | 14 | config := zap.Config{ 15 | Level: zap.NewAtomicLevelAt(zapLevel), 16 | Development: false, 17 | Encoding: "json", 18 | EncoderConfig: zap.NewProductionEncoderConfig(), 19 | OutputPaths: []string{"stdout"}, 20 | ErrorOutputPaths: []string{"stdout"}, 21 | } 22 | 23 | logger, err := config.Build() 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return logger, nil 29 | } 30 | -------------------------------------------------------------------------------- /internal/gql/resolver/tagquery.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *queryResolver) Tag(ctx context.Context) (*model.TagQuery, error) { 14 | return &model.TagQuery{}, nil 15 | } 16 | 17 | // TagQuery returns runtime.TagQueryResolver implementation. 18 | func (r *Resolver) TagQuery() runtime.TagQueryResolver { return &tagQueryResolver{r} } 19 | 20 | type tagQueryResolver struct{ *Resolver } 21 | -------------------------------------------------------------------------------- /internal/feature/articleblock/entity.go: -------------------------------------------------------------------------------- 1 | package articleblock 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "time" 6 | ) 7 | 8 | type ArticleBlockData map[string]interface{} 9 | 10 | type ArticleBlock struct { 11 | tableName struct{} `pg:"article_block"` 12 | 13 | ArticleID uuid.UUID `pg:"type:uuid"` 14 | CreatedAt time.Time `pg:",notnull,use_zero"` 15 | Data ArticleBlockData `pg:",notnull,use_zero"` 16 | DeletedAt *time.Time 17 | ID uuid.UUID `pg:"type:uuid"` 18 | ModifiedAt time.Time `pg:",notnull,use_zero"` 19 | SortRank string `pg:",notnull,use_zero"` 20 | Type ArticleBlockType `pg:",notnull,use_zero"` 21 | Version uint `pg:",notnull,use_zero"` 22 | } 23 | -------------------------------------------------------------------------------- /internal/gql/resolver/imagequery.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *queryResolver) Image(ctx context.Context) (*model.ImageQuery, error) { 14 | return &model.ImageQuery{}, nil 15 | } 16 | 17 | // ImageQuery returns runtime.ImageQueryResolver implementation. 18 | func (r *Resolver) ImageQuery() runtime.ImageQueryResolver { return &imageQueryResolver{r} } 19 | 20 | type imageQueryResolver struct{ *Resolver } 21 | -------------------------------------------------------------------------------- /internal/gql/resolver/tagmutation.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *mutationResolver) Tag(ctx context.Context) (*model.TagMutation, error) { 14 | return &model.TagMutation{}, nil 15 | } 16 | 17 | // TagMutation returns runtime.TagMutationResolver implementation. 18 | func (r *Resolver) TagMutation() runtime.TagMutationResolver { return &tagMutationResolver{r} } 19 | 20 | type tagMutationResolver struct{ *Resolver } 21 | -------------------------------------------------------------------------------- /internal/gql/resolver/articlequery.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *queryResolver) Article(ctx context.Context) (*model.ArticleQuery, error) { 14 | return &model.ArticleQuery{}, nil 15 | } 16 | 17 | // ArticleQuery returns runtime.ArticleQueryResolver implementation. 18 | func (r *Resolver) ArticleQuery() runtime.ArticleQueryResolver { return &articleQueryResolver{r} } 19 | 20 | type articleQueryResolver struct{ *Resolver } 21 | -------------------------------------------------------------------------------- /internal/gql/resolver/projectquery.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *queryResolver) Project(ctx context.Context) (*model.ProjectQuery, error) { 14 | return &model.ProjectQuery{}, nil 15 | } 16 | 17 | // ProjectQuery returns runtime.ProjectQueryResolver implementation. 18 | func (r *Resolver) ProjectQuery() runtime.ProjectQueryResolver { return &projectQueryResolver{r} } 19 | 20 | type projectQueryResolver struct{ *Resolver } 21 | -------------------------------------------------------------------------------- /internal/feature/article/mapper.go: -------------------------------------------------------------------------------- 1 | package article 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/gql/model" 5 | ) 6 | 7 | func MapOneToGqlModel(article Article) *model.Article { 8 | return &model.Article{ 9 | CoverImageID: article.CoverImageID, 10 | CreatedAt: article.CreatedAt, 11 | DeletedAt: article.DeletedAt, 12 | ID: article.ID, 13 | ModifiedAt: article.ModifiedAt, 14 | ProjectID: article.ProjectID, 15 | Title: article.Title, 16 | Version: article.Version, 17 | } 18 | } 19 | 20 | func MapManyToGqlModels(articles []Article) []*model.Article { 21 | items := make([]*model.Article, len(articles)) 22 | 23 | for i, entity := range articles { 24 | items[i] = MapOneToGqlModel(entity) 25 | } 26 | 27 | return items 28 | } 29 | -------------------------------------------------------------------------------- /internal/feature/articletag/mapper.go: -------------------------------------------------------------------------------- 1 | package articletag 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/gql/model" 5 | ) 6 | 7 | func MapOneToGqlModel(articleTag ArticleTag) *model.ArticleTag { 8 | return &model.ArticleTag{ 9 | ArticleID: articleTag.ArticleID, 10 | CreatedAt: articleTag.CreatedAt, 11 | ID: articleTag.ID, 12 | ModifiedAt: articleTag.ModifiedAt, 13 | SortRank: articleTag.SortRank, 14 | TagID: articleTag.TagID, 15 | Version: articleTag.Version, 16 | } 17 | } 18 | 19 | func MapManyToGqlModels(articleTags []ArticleTag) []*model.ArticleTag { 20 | items := make([]*model.ArticleTag, len(articleTags)) 21 | 22 | for i, entity := range articleTags { 23 | items[i] = MapOneToGqlModel(entity) 24 | } 25 | 26 | return items 27 | } 28 | -------------------------------------------------------------------------------- /internal/gql/resolver/imagemutation.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *mutationResolver) Image(ctx context.Context) (*model.ImageMutation, error) { 14 | return &model.ImageMutation{}, nil 15 | } 16 | 17 | // ImageMutation returns runtime.ImageMutationResolver implementation. 18 | func (r *Resolver) ImageMutation() runtime.ImageMutationResolver { return &imageMutationResolver{r} } 19 | 20 | type imageMutationResolver struct{ *Resolver } 21 | -------------------------------------------------------------------------------- /internal/gql/resolver/articlemutation.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *mutationResolver) Article(ctx context.Context) (*model.ArticleMutation, error) { 14 | return &model.ArticleMutation{}, nil 15 | } 16 | 17 | // ArticleMutation returns runtime.ArticleMutationResolver implementation. 18 | func (r *Resolver) ArticleMutation() runtime.ArticleMutationResolver { 19 | return &articleMutationResolver{r} 20 | } 21 | 22 | type articleMutationResolver struct{ *Resolver } 23 | -------------------------------------------------------------------------------- /internal/gql/resolver/projectmutation.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *mutationResolver) Project(ctx context.Context) (*model.ProjectMutation, error) { 14 | return &model.ProjectMutation{}, nil 15 | } 16 | 17 | // ProjectMutation returns runtime.ProjectMutationResolver implementation. 18 | func (r *Resolver) ProjectMutation() runtime.ProjectMutationResolver { 19 | return &projectMutationResolver{r} 20 | } 21 | 22 | type projectMutationResolver struct{ *Resolver } 23 | -------------------------------------------------------------------------------- /internal/gql/model/uint.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/99designs/gqlgen/graphql" 7 | "io" 8 | "strconv" 9 | ) 10 | 11 | type UInt uint 12 | 13 | func MarshalUInt(i uint) graphql.Marshaler { 14 | return graphql.WriterFunc(func(w io.Writer) { 15 | io.WriteString(w, strconv.FormatUint(uint64(i), 10)) 16 | }) 17 | } 18 | 19 | func UnmarshalUInt(v interface{}) (uint, error) { 20 | switch v := v.(type) { 21 | case string: 22 | u64, err := strconv.ParseUint(v, 10, 32) 23 | return uint(u64), err 24 | case int: 25 | return uint(v), nil 26 | case int64: 27 | return uint(v), nil 28 | case json.Number: 29 | u64, err := strconv.ParseUint(string(v), 10, 32) 30 | return uint(u64), err 31 | default: 32 | return 0, fmt.Errorf("%T is not an uint", v) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/feature/image/mapper.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/gql/model" 5 | ) 6 | 7 | func MapOneToGqlModel(image Image) (*model.Image, error) { 8 | return &model.Image{ 9 | CreatedAt: image.CreatedAt, 10 | DeletedAt: image.DeletedAt, 11 | Type: image.Type, 12 | Height: uint(image.Height), 13 | ID: image.ID, 14 | ModifiedAt: image.ModifiedAt, 15 | Version: image.Version, 16 | Width: uint(image.Width), 17 | }, nil 18 | } 19 | 20 | func MapManyToGqlModels(images []Image) ([]*model.Image, error) { 21 | items := make([]*model.Image, len(images)) 22 | 23 | for i, entity := range images { 24 | m, err := MapOneToGqlModel(entity) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | items[i] = m 30 | } 31 | 32 | return items, nil 33 | } -------------------------------------------------------------------------------- /internal/gql/resolver/articleblockquery.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *queryResolver) ArticleBlock(ctx context.Context) (*model.ArticleBlockQuery, error) { 14 | return &model.ArticleBlockQuery{}, nil 15 | } 16 | 17 | // ArticleBlockQuery returns runtime.ArticleBlockQueryResolver implementation. 18 | func (r *Resolver) ArticleBlockQuery() runtime.ArticleBlockQueryResolver { 19 | return &articleBlockQueryResolver{r} 20 | } 21 | 22 | type articleBlockQueryResolver struct{ *Resolver } 23 | -------------------------------------------------------------------------------- /internal/gql/resolver/articletagmutation.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *mutationResolver) ArticleTag(ctx context.Context) (*model.ArticleTagMutation, error) { 14 | return &model.ArticleTagMutation{}, nil 15 | } 16 | 17 | // ArticleTagMutation returns runtime.ArticleTagMutationResolver implementation. 18 | func (r *Resolver) ArticleTagMutation() runtime.ArticleTagMutationResolver { 19 | return &articleTagMutationResolver{r} 20 | } 21 | 22 | type articleTagMutationResolver struct{ *Resolver } 23 | -------------------------------------------------------------------------------- /api/articleblockmutation_update.graphql: -------------------------------------------------------------------------------- 1 | extend type ArticleBlockMutation { 2 | update(input: ArticleBlockUpdateInput!): ArticleBlockUpdateResult! @goField(forceResolver: true) 3 | } 4 | 5 | input ArticleBlockUpdateInput { 6 | id: Uuid! 7 | data: ArticleBlockDataInput! @inputUnion 8 | version: UInt! 9 | } 10 | 11 | input ArticleBlockDataInput { 12 | html: ArticleBlockHtmlDataInput 13 | image: ArticleBlockImageDataInput 14 | } 15 | 16 | type ArticleBlockUpdateOk { 17 | articleBlock: ArticleBlockInterface! 18 | } 19 | 20 | type ArticleBlockTypeMismatchProblem implements ProblemInterface { 21 | message: String! 22 | } 23 | 24 | union ArticleBlockUpdateResult = 25 | | ArticleBlockNotFoundProblem 26 | | ArticleBlockTypeMismatchProblem 27 | | VersionMismatchProblem 28 | | InternalErrorProblem 29 | | ArticleBlockUpdateOk 30 | -------------------------------------------------------------------------------- /internal/gql/model/pagesize.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/99designs/gqlgen/graphql" 6 | "io" 7 | "strconv" 8 | ) 9 | 10 | const pageSizeMin uint = 1 11 | const pageSizeMax uint = 100 12 | 13 | type PageSize uint 14 | 15 | func MarshalPageSize(i uint) graphql.Marshaler { 16 | return graphql.WriterFunc(func(w io.Writer) { 17 | io.WriteString(w, strconv.FormatUint(uint64(i), 10)) 18 | }) 19 | } 20 | 21 | func UnmarshalPageSize(v interface{}) (uint, error) { 22 | parsed, err := UnmarshalUInt(v) 23 | if err != nil { 24 | return 0, err 25 | } 26 | 27 | if parsed < pageSizeMin { 28 | return 0, fmt.Errorf("value must be greater or equal than %d", pageSizeMin) 29 | } 30 | 31 | if parsed > pageSizeMax { 32 | return 0, fmt.Errorf("value must be less or equal than %d", pageSizeMax) 33 | } 34 | 35 | return parsed, nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/gql/resolver/articleblockmutation.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *mutationResolver) ArticleBlock(ctx context.Context) (*model.ArticleBlockMutation, error) { 14 | return &model.ArticleBlockMutation{}, nil 15 | } 16 | 17 | // ArticleBlockMutation returns runtime.ArticleBlockMutationResolver implementation. 18 | func (r *Resolver) ArticleBlockMutation() runtime.ArticleBlockMutationResolver { 19 | return &articleBlockMutationResolver{r} 20 | } 21 | 22 | type articleBlockMutationResolver struct{ *Resolver } 23 | -------------------------------------------------------------------------------- /internal/gql/model/pagenumber.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "github.com/99designs/gqlgen/graphql" 6 | "io" 7 | "strconv" 8 | ) 9 | 10 | const pageNumberMin uint = 1 11 | const pageNumberMax uint = 100 12 | 13 | type PageNumber uint 14 | 15 | func MarshalPageNumber(i uint) graphql.Marshaler { 16 | return graphql.WriterFunc(func(w io.Writer) { 17 | io.WriteString(w, strconv.FormatUint(uint64(i), 10)) 18 | }) 19 | } 20 | 21 | func UnmarshalPageNumber(v interface{}) (uint, error) { 22 | parsed, err := UnmarshalUInt(v) 23 | if err != nil { 24 | return 0, err 25 | } 26 | 27 | if parsed < pageSizeMin { 28 | return 0, fmt.Errorf("value must be greater or equal than %d", pageNumberMin) 29 | } 30 | 31 | if parsed > pageSizeMax { 32 | return 0, fmt.Errorf("value must be less or equal than %d", pageNumberMax) 33 | } 34 | 35 | return parsed, nil 36 | } 37 | -------------------------------------------------------------------------------- /api/articleblockquery_find.graphql: -------------------------------------------------------------------------------- 1 | extend type ArticleBlockQuery { 2 | find( 3 | filter: ArticleBlockFindFilterInput 4 | sort: ArticleBlockFindSortEnum! = SORT_RANK_ASC 5 | pageSize: PageSize! = 10 6 | pageNumber: PageNumber! = 1 7 | ): ArticleBlockFindResult! @goField(forceResolver: true) 8 | } 9 | 10 | enum ArticleBlockFindSortEnum { 11 | CREATED_AT_ASC 12 | CREATED_AT_DESC 13 | SORT_RANK_ASC 14 | SORT_RANK_DESC 15 | } 16 | 17 | input ArticleBlockFindFilterInput { 18 | articleIdAnyOf: [Uuid!] 19 | idAnyOf: [Uuid!] 20 | typeAnyOf: [ArticleBlockTypeEnum!] 21 | } 22 | 23 | type ArticleBlockFindList { 24 | items: [ArticleBlockInterface!]! 25 | totalCount(estimate: UInt! = 10000): TotalCountResolvingResult! @goField(forceResolver: true) 26 | } 27 | 28 | union ArticleBlockFindResult = 29 | | ArticleBlockFindList 30 | | InternalErrorProblem -------------------------------------------------------------------------------- /api/article.graphql: -------------------------------------------------------------------------------- 1 | type Article implements VersionInterface { 2 | content: ArticleContentResolvingResult! @goField(forceResolver: true) 3 | coverImage: ImageResolvingResult! @goField(forceResolver: true) 4 | coverImageId: Uuid 5 | createdAt: Rfc3339Date! 6 | deletedAt: Rfc3339Date 7 | id: Uuid! 8 | modifiedAt: Rfc3339Date! 9 | project: ProjectResolvingResult! @goField(forceResolver: true) 10 | projectId: Uuid! 11 | tags: ArticleTagsResolvingResult! @goField(forceResolver: true) 12 | title: String! 13 | version: UInt! 14 | } 15 | 16 | type ArticleContent { 17 | blocks: [ArticleBlockInterface!]! 18 | } 19 | 20 | type ArticleNotFoundProblem implements ProblemInterface { 21 | message: String! 22 | } 23 | 24 | union ArticleContentResolvingResult = 25 | | ArticleContent 26 | | InternalErrorProblem 27 | 28 | union ArticleTagsResolvingResult = 29 | | ArticleTagList 30 | | InternalErrorProblem -------------------------------------------------------------------------------- /.gqlgen.yml: -------------------------------------------------------------------------------- 1 | schema: 2 | - ./api/**/*.graphql 3 | 4 | exec: 5 | filename: internal/gql/runtime/generated.go 6 | package: runtime 7 | 8 | model: 9 | filename: internal/gql/model/generated.go 10 | package: model 11 | 12 | resolver: 13 | layout: follow-schema 14 | dir: internal/gql/resolver 15 | package: resolver 16 | filename_template: "{name}.go" 17 | 18 | models: 19 | Html: 20 | model: github.com/99designs/gqlgen/graphql.String 21 | PageNumber: 22 | model: github.com/acelot/articles/internal/gql/model.PageNumber 23 | PageSize: 24 | model: github.com/acelot/articles/internal/gql/model.PageSize 25 | Rfc3339Date: 26 | model: github.com/acelot/articles/internal/gql/model.Rfc3339Date 27 | UInt: 28 | model: github.com/acelot/articles/internal/gql/model.UInt 29 | Url: 30 | model: github.com/acelot/articles/internal/gql/model.Url 31 | Uuid: 32 | model: github.com/acelot/articles/internal/gql/model.Uuid 33 | -------------------------------------------------------------------------------- /internal/feature/image/storage.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "github.com/google/uuid" 6 | "github.com/minio/minio-go/v7" 7 | "io" 8 | "net/url" 9 | "time" 10 | ) 11 | 12 | type Storage struct { 13 | client *minio.Client 14 | bucketName string 15 | } 16 | 17 | func NewStorage(client *minio.Client, bucketName string) *Storage { 18 | return &Storage{client, bucketName} 19 | } 20 | 21 | func (s *Storage) Store(ctx context.Context, id uuid.UUID, mimeType string, fileSize int64, reader io.Reader) error { 22 | _, err := s.client.PutObject( 23 | ctx, 24 | s.bucketName, 25 | id.String(), 26 | reader, 27 | fileSize, 28 | minio.PutObjectOptions{ 29 | ContentType: mimeType, 30 | }, 31 | ) 32 | 33 | return err 34 | } 35 | 36 | func (s *Storage) MakeTempURL(ctx context.Context, id uuid.UUID, ttl time.Duration) (*url.URL, error) { 37 | return s.client.PresignedGetObject(ctx, s.bucketName, id.String(), ttl, url.Values{}) 38 | } -------------------------------------------------------------------------------- /internal/migration/args.go: -------------------------------------------------------------------------------- 1 | package migration 2 | 3 | import ( 4 | "fmt" 5 | "github.com/docopt/docopt-go" 6 | "github.com/go-pg/pg/v10" 7 | "os" 8 | ) 9 | 10 | type Args struct { 11 | IsUpdate bool 12 | IsCreate bool 13 | Directory string 14 | DatabaseDsn string 15 | } 16 | 17 | func NewArgs(opts *docopt.Opts) (*Args, error) { 18 | isUpdate, _ := opts.Bool("update") 19 | isCreate, _ := opts.Bool("create") 20 | 21 | directory, _ := opts.String("--dir") 22 | if _, err := os.Stat(directory); err != nil { 23 | return nil, fmt.Errorf("invalid argument --dir; %s", err) 24 | } 25 | 26 | databaseDsn, _ := opts.String("") 27 | if databaseDsn != "" { 28 | if _, err := pg.ParseURL(databaseDsn); err != nil { 29 | return nil, fmt.Errorf("invalid parameter ; format: postgres://user:pass@host:port/db?option=value") 30 | } 31 | } 32 | 33 | return &Args{ 34 | IsUpdate: isUpdate, 35 | IsCreate: isCreate, 36 | Directory: directory, 37 | DatabaseDsn: databaseDsn, 38 | }, nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/gql/resolver/tagmutation_create.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/tag" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *tagMutationResolver) Create(ctx context.Context, obj *model.TagMutation, input model.TagCreateInput) (model.TagCreateResult, error) { 16 | createdTag, err := r.env.Services.TagService.CreateTag(input) 17 | 18 | if errors.Is(err, tag.TagNameConstraintError) { 19 | return model.TagAlreadyExistsProblem{Message: "tag already exists"}, nil 20 | } 21 | 22 | if err != nil { 23 | r.env.Logger.Error("tag.Service.CreateTag", zap.Error(err)) 24 | 25 | return NewInternalErrorProblem(), nil 26 | } 27 | 28 | return model.TagCreateOk{ 29 | Tag: tag.MapOneToGqlModel(*createdTag), 30 | }, nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/gql/resolver/image.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/gql/model" 10 | "github.com/acelot/articles/internal/gql/runtime" 11 | ) 12 | 13 | func (r *imageResolver) Assets(ctx context.Context, obj *model.Image) ([]*model.ImageAsset, error) { 14 | return []*model.ImageAsset{}, nil 15 | } 16 | 17 | func (r *imageResolver) Download(ctx context.Context, obj *model.Image) (model.ImageDownloadResolvingResult, error) { 18 | tempURL, err := r.env.Services.ImageService.GetImageDownloadURL(obj.ID) 19 | if err != nil { 20 | return NewInternalErrorProblem(), nil 21 | } 22 | 23 | return model.ImageDownload{URL: *tempURL}, nil 24 | } 25 | 26 | // Image returns runtime.ImageResolver implementation. 27 | func (r *Resolver) Image() runtime.ImageResolver { return &imageResolver{r} } 28 | 29 | type imageResolver struct{ *Resolver } 30 | -------------------------------------------------------------------------------- /internal/app/env.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "go.uber.org/zap" 5 | ) 6 | 7 | type Env struct { 8 | Logger *zap.Logger 9 | Databases *Databases 10 | Storages *Storages 11 | Repositories *Repositories 12 | Services *Services 13 | } 14 | 15 | func NewEnv(args *Args) (*Env, error) { 16 | logger, err := NewLogger(args.LogLevel) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | databases, err := NewDatabases(args.PrimaryDatabaseDSN, args.SecondaryDatabaseDSN, logger) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | storages, err := NewStorages(args.ImageStorageURI) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | repos := NewRepositories(databases) 32 | 33 | services := NewServices(repos, storages) 34 | 35 | env := Env{ 36 | Logger: logger, 37 | Databases: databases, 38 | Storages: storages, 39 | Repositories: repos, 40 | Services: services, 41 | } 42 | 43 | return &env, nil 44 | } 45 | 46 | func (env *Env) Close() error { 47 | if err := env.Databases.Close(); err != nil { 48 | return nil 49 | } 50 | 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /internal/gql/resolver/articlemutation_create.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/article" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *articleMutationResolver) Create(ctx context.Context, obj *model.ArticleMutation, input model.ArticleCreateInput) (model.ArticleCreateResult, error) { 16 | createdArticle, err := r.env.Services.ArticleService.CreateArticle(input.ProjectID) 17 | 18 | if errors.Is(err, article.ProjectIDConstraintError) { 19 | return model.ProjectNotFoundProblem{Message: "project not found"}, nil 20 | } 21 | 22 | if err != nil { 23 | r.env.Logger.Error("article.Service.CreateArticle", zap.Error(err)) 24 | 25 | return NewInternalErrorProblem(), nil 26 | } 27 | 28 | return model.ArticleCreateOk{ 29 | Article: article.MapOneToGqlModel(*createdArticle), 30 | }, nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/gql/resolver/projectmutation_create.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/project" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *projectMutationResolver) Create(ctx context.Context, obj *model.ProjectMutation, input model.ProjectCreateInput) (model.ProjectCreateResult, error) { 16 | createdProject, err := r.env.Services.ProjectService.CreateProject(input) 17 | 18 | if errors.Is(err, project.ProjectNameConstraintError) { 19 | return model.ProjectAlreadyExistsProblem{Message: "project already exists"}, nil 20 | } 21 | 22 | if err != nil { 23 | r.env.Logger.Error("project.Service.CreateProject", zap.Error(err)) 24 | 25 | return NewInternalErrorProblem(), nil 26 | } 27 | 28 | return model.ProjectCreateOk{ 29 | Project: project.MapOneToGqlModel(*createdProject), 30 | }, nil 31 | } 32 | -------------------------------------------------------------------------------- /internal/app/storages.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/feature/image" 5 | "github.com/minio/minio-go/v7" 6 | "github.com/minio/minio-go/v7/pkg/credentials" 7 | "net/url" 8 | "strings" 9 | ) 10 | 11 | type Storages struct { 12 | ImageStorage *image.Storage 13 | } 14 | 15 | func NewStorages(imageStorageURI string) (*Storages, error) { 16 | imageStorage, err := newImageStorage(imageStorageURI) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | return &Storages{ 22 | ImageStorage: imageStorage, 23 | }, nil 24 | } 25 | 26 | func newImageStorage(uri string) (*image.Storage, error) { 27 | parsed, _ := url.Parse(uri) 28 | 29 | endpoint := parsed.Host 30 | bucketName := strings.Trim(parsed.Path, "/") 31 | id := parsed.User.Username() 32 | secret, _ := parsed.User.Password() 33 | useSSL := parsed.Scheme == "https" 34 | 35 | client, err := minio.New(endpoint, &minio.Options{ 36 | Creds: credentials.NewStaticV4(id, secret, ""), 37 | Secure: useSSL, 38 | }) 39 | 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return image.NewStorage(client, bucketName), nil 45 | } -------------------------------------------------------------------------------- /api/image.graphql: -------------------------------------------------------------------------------- 1 | type Image implements VersionInterface { 2 | assets: [ImageAsset!]! @goField(forceResolver: true) 3 | createdAt: Rfc3339Date! 4 | deletedAt: Rfc3339Date 5 | type: String! 6 | height: UInt! 7 | id: Uuid! 8 | modifiedAt: Rfc3339Date! 9 | download: ImageDownloadResolvingResult! @goField(forceResolver: true) 10 | version: UInt! 11 | width: UInt! 12 | } 13 | 14 | type ImageAsset { 15 | format: ImageAssetFormat! 16 | width: ImageAssetWidth! 17 | url: Url! 18 | } 19 | 20 | type ImageDownload { 21 | url: Url! 22 | } 23 | 24 | type ImageNotFoundProblem implements ProblemInterface { 25 | message: String! 26 | } 27 | 28 | union ImageResolvingResult = 29 | | Image 30 | | ImageNotFoundProblem 31 | | InternalErrorProblem 32 | 33 | union ImageDownloadResolvingResult = 34 | | ImageDownload 35 | | InternalErrorProblem 36 | 37 | enum ImageAssetFormat { 38 | AVIF 39 | JPEG 40 | PNG 41 | WEBP 42 | } 43 | 44 | enum ImageAssetWidth { 45 | W320 46 | W768 47 | W1024 48 | W1280 49 | W1366 50 | W1600 51 | W1920 52 | W2560 53 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Valeriy Protopopov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/gql/resolver/tagmutation_update.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/tag" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *tagMutationResolver) Update(ctx context.Context, obj *model.TagMutation, input model.TagUpdateInput) (model.TagUpdateResult, error) { 16 | updatedTag, err := r.env.Services.TagService.UpdateTag(input) 17 | 18 | if errors.Is(err, tag.NotFoundError) { 19 | return model.TagNotFoundProblem{Message: "tag not found"}, nil 20 | } 21 | 22 | if errors.Is(err, tag.VersionMismatchError) { 23 | return NewVersionMismatchProblem(), nil 24 | } 25 | 26 | if err != nil { 27 | r.env.Logger.Error("tag.Service.UpdateTag", zap.Error(err)) 28 | 29 | return NewInternalErrorProblem(), nil 30 | } 31 | 32 | return model.TagUpdateOk{ 33 | Tag: tag.MapOneToGqlModel(*updatedTag), 34 | }, nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/gql/directive/inputunion.go: -------------------------------------------------------------------------------- 1 | package directive 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/99designs/gqlgen/graphql" 7 | "reflect" 8 | ) 9 | 10 | type InputUnionDirectiveFunc = func( 11 | ctx context.Context, 12 | obj interface{}, 13 | next graphql.Resolver, 14 | ) (res interface{}, err error) 15 | 16 | func NewInputUnionDirective() InputUnionDirectiveFunc { 17 | return func( 18 | ctx context.Context, 19 | obj interface{}, 20 | next graphql.Resolver, 21 | ) (res interface{}, err error) { 22 | inputObj, err := next(ctx) 23 | if err != nil { 24 | return inputObj, err 25 | } 26 | 27 | v := reflect.ValueOf(inputObj) 28 | if v.Kind() == reflect.Ptr { 29 | v = v.Elem() 30 | } 31 | 32 | valueFound := false 33 | 34 | for i := 0; i < v.NumField(); i++ { 35 | if !v.Field(i).IsNil() { 36 | if valueFound { 37 | return inputObj, errors.New("only one field of the input union should have a value") 38 | } 39 | 40 | valueFound = true 41 | } 42 | } 43 | 44 | if !valueFound { 45 | return inputObj, errors.New("one of the input union fields must have a value") 46 | } 47 | 48 | return inputObj, nil 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/gql/resolver/articlemutation_update.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/article" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *articleMutationResolver) Update(ctx context.Context, obj *model.ArticleMutation, input model.ArticleUpdateInput) (model.ArticleUpdateResult, error) { 16 | updatedArticle, err := r.env.Services.ArticleService.UpdateArticle(input) 17 | 18 | if errors.Is(err, article.NotFoundError) { 19 | return model.ArticleNotFoundProblem{Message: "article not found"}, nil 20 | } 21 | 22 | if errors.Is(err, article.VersionMismatchError) { 23 | return NewVersionMismatchProblem(), nil 24 | } 25 | 26 | if err != nil { 27 | r.env.Logger.Error("article.Service.UpdateArticle", zap.Error(err)) 28 | 29 | return NewInternalErrorProblem(), nil 30 | } 31 | 32 | return model.ArticleUpdateOk{ 33 | Article: article.MapOneToGqlModel(*updatedArticle), 34 | }, nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/gql/resolver/articletag.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/app" 10 | "github.com/acelot/articles/internal/feature/tag" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "github.com/acelot/articles/internal/gql/runtime" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *articleTagResolver) Tag(ctx context.Context, obj *model.ArticleTag) (model.TagResolvingResult, error) { 17 | dataLoader := ctx.Value(app.DataLoadersContextKey).(*app.DataLoaders).TagLoaderByID 18 | 19 | proj, err := dataLoader.Load(tag.LoaderByIDKey{ID: obj.TagID}) 20 | if err != nil { 21 | r.env.Logger.Error("tag.LoaderByID.Load", zap.Error(err)) 22 | 23 | return NewInternalErrorProblem(), nil 24 | } 25 | 26 | return tag.MapOneToGqlModel(*proj), nil 27 | } 28 | 29 | // ArticleTag returns runtime.ArticleTagResolver implementation. 30 | func (r *Resolver) ArticleTag() runtime.ArticleTagResolver { return &articleTagResolver{r} } 31 | 32 | type articleTagResolver struct{ *Resolver } 33 | -------------------------------------------------------------------------------- /internal/gql/resolver/projectmutation_update.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/project" 11 | "github.com/acelot/articles/internal/feature/tag" 12 | "github.com/acelot/articles/internal/gql/model" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *projectMutationResolver) Update(ctx context.Context, obj *model.ProjectMutation, input model.ProjectUpdateInput) (model.ProjectUpdateResult, error) { 17 | updatedProject, err := r.env.Services.ProjectService.UpdateProject(input) 18 | 19 | if errors.Is(err, tag.NotFoundError) { 20 | return model.ProjectNotFoundProblem{Message: "project not found"}, nil 21 | } 22 | 23 | if errors.Is(err, tag.VersionMismatchError) { 24 | return NewVersionMismatchProblem(), nil 25 | } 26 | 27 | if err != nil { 28 | r.env.Logger.Error("project.Service.UpdateProject", zap.Error(err)) 29 | 30 | return NewInternalErrorProblem(), nil 31 | } 32 | 33 | return model.ProjectUpdateOk{ 34 | Project: project.MapOneToGqlModel(*updatedProject), 35 | }, nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/app/services.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/feature/article" 5 | "github.com/acelot/articles/internal/feature/articleblock" 6 | "github.com/acelot/articles/internal/feature/articletag" 7 | "github.com/acelot/articles/internal/feature/image" 8 | "github.com/acelot/articles/internal/feature/project" 9 | "github.com/acelot/articles/internal/feature/tag" 10 | ) 11 | 12 | type Services struct { 13 | ArticleService *article.Service 14 | ArticleBlockService *articleblock.Service 15 | ArticleTagService *articletag.Service 16 | ImageService *image.Service 17 | TagService *tag.Service 18 | ProjectService *project.Service 19 | } 20 | 21 | func NewServices(repos *Repositories, storages *Storages) *Services { 22 | return &Services{ 23 | ArticleService: article.NewService(repos.ArticleRepository), 24 | ArticleBlockService: articleblock.NewService(repos.ArticleBlockRepository), 25 | ArticleTagService: articletag.NewService(repos.ArticleTagRepository), 26 | ImageService: image.NewService(repos.ImageRepository, storages.ImageStorage), 27 | TagService: tag.NewService(repos.TagRepository), 28 | ProjectService: project.NewService(repos.ProjectRepository), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/gql/directive/sortrankinput.go: -------------------------------------------------------------------------------- 1 | package directive 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/99designs/gqlgen/graphql" 7 | "github.com/acelot/articles/internal/gql/model" 8 | ) 9 | 10 | type SortRankInputDirectiveFunc = func( 11 | ctx context.Context, 12 | obj interface{}, 13 | next graphql.Resolver, 14 | ) (res interface{}, err error) 15 | 16 | func NewSortRankInputDirective() SortRankInputDirectiveFunc { 17 | return func( 18 | ctx context.Context, 19 | obj interface{}, 20 | next graphql.Resolver, 21 | ) (res interface{}, err error) { 22 | inputObj, err := next(ctx) 23 | if err != nil { 24 | return inputObj, err 25 | } 26 | 27 | input, ok := inputObj.(*model.SortRankInput) 28 | if !ok { 29 | panic("@sortRankInput directive should only be used with SortRankInput input type") 30 | } 31 | 32 | if input.Prev < "0" || input.Prev >= "z" { 33 | return inputObj, errors.New("invalid prev value, must be in range of `[0-z)`") 34 | } 35 | 36 | if input.Next <= "0" || input.Next > "z" { 37 | return inputObj, errors.New("invalid next value, must be in range of `(0-z]`") 38 | } 39 | 40 | if input.Prev >= input.Next { 41 | return inputObj, errors.New("next value must be greater than prev value") 42 | } 43 | 44 | return inputObj, nil 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/acelot/articles 2 | 3 | go 1.16 4 | 5 | require ( 6 | github.com/99designs/gqlgen v0.13.0 7 | github.com/agnivade/levenshtein v1.1.0 // indirect 8 | github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect 9 | github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 10 | github.com/go-pg/pg/v10 v10.9.1 11 | github.com/google/uuid v1.2.0 12 | github.com/h2non/bimg v1.1.5 // indirect 13 | github.com/hashicorp/golang-lru v0.5.4 // indirect 14 | github.com/matryer/moq v0.2.1 // indirect 15 | github.com/minio/minio-go/v7 v7.0.10 // indirect 16 | github.com/mitchellh/mapstructure v1.4.1 // indirect 17 | github.com/pkg/errors v0.9.1 // indirect 18 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 19 | github.com/segmentio/ksuid v1.0.3 // indirect 20 | github.com/urfave/cli/v2 v2.3.0 // indirect 21 | github.com/vektah/dataloaden v0.3.0 // indirect 22 | github.com/vektah/gqlparser/v2 v2.1.0 23 | github.com/xissy/lexorank v0.0.1 // indirect 24 | go.uber.org/multierr v1.6.0 // indirect 25 | go.uber.org/zap v1.16.0 26 | golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 // indirect 27 | golang.org/x/mod v0.4.2 // indirect 28 | golang.org/x/net v0.0.0-20210421230115-4e50805a0758 // indirect 29 | golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6 // indirect 30 | golang.org/x/tools v0.1.0 // indirect 31 | gopkg.in/yaml.v2 v2.4.0 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /internal/app/repositories.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/feature/article" 5 | "github.com/acelot/articles/internal/feature/articleblock" 6 | "github.com/acelot/articles/internal/feature/articletag" 7 | "github.com/acelot/articles/internal/feature/image" 8 | "github.com/acelot/articles/internal/feature/project" 9 | "github.com/acelot/articles/internal/feature/tag" 10 | ) 11 | 12 | type Repositories struct { 13 | ArticleRepository *article.Repository 14 | ArticleBlockRepository *articleblock.Repository 15 | ArticleTagRepository *articletag.Repository 16 | ImageRepository *image.Repository 17 | ProjectRepository *project.Repository 18 | TagRepository *tag.Repository 19 | } 20 | 21 | func NewRepositories(databases *Databases) *Repositories { 22 | return &Repositories{ 23 | ArticleRepository: article.NewRepository(databases.PrimaryDB, databases.SecondaryDB), 24 | ArticleBlockRepository: articleblock.NewRepository(databases.PrimaryDB, databases.SecondaryDB), 25 | ArticleTagRepository: articletag.NewRepository(databases.PrimaryDB, databases.SecondaryDB), 26 | ImageRepository: image.NewRepository(databases.PrimaryDB, databases.SecondaryDB), 27 | ProjectRepository: project.NewRepository(databases.PrimaryDB, databases.SecondaryDB), 28 | TagRepository: tag.NewRepository(databases.PrimaryDB, databases.SecondaryDB), 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/gql/resolver/articleblockmutation_move.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/articleblock" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *articleBlockMutationResolver) Move(ctx context.Context, obj *model.ArticleBlockMutation, input model.ArticleBlockMoveInput) (model.ArticleBlockMoveResult, error) { 16 | movedArticleBlock, err := r.env.Services.ArticleBlockService.MoveArticleBlock(input) 17 | 18 | if errors.Is(err, articleblock.ArticleNotFoundError) { 19 | return model.ArticleBlockNotFoundProblem{Message: "article block not found"}, nil 20 | } 21 | 22 | if errors.Is(err, articleblock.InvalidSortRankError) { 23 | return model.InvalidSortRankProblem{Message: "outdated sort rankings"}, nil 24 | } 25 | 26 | if errors.Is(err, articleblock.VersionMismatchError) { 27 | return NewVersionMismatchProblem(), nil 28 | } 29 | 30 | if err != nil { 31 | r.env.Logger.Error("articleblock.Service.MoveArticleBlock", zap.Error(err)) 32 | 33 | return NewInternalErrorProblem(), nil 34 | } 35 | 36 | return model.ArticleBlockMoveOk{ 37 | SortRank: movedArticleBlock.SortRank, 38 | }, nil 39 | } 40 | -------------------------------------------------------------------------------- /internal/gql/resolver/articletagmutation_move.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/articleblock" 11 | "github.com/acelot/articles/internal/feature/articletag" 12 | "github.com/acelot/articles/internal/gql/model" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *articleTagMutationResolver) Move(ctx context.Context, obj *model.ArticleTagMutation, input model.ArticleTagMoveInput) (model.ArticleTagMoveResult, error) { 17 | movedArticleTag, err := r.env.Services.ArticleTagService.MoveArticleTag(input) 18 | 19 | if errors.Is(err, articletag.NotFoundError) { 20 | return model.ArticleTagNotFoundProblem{Message: "article tag not found"}, nil 21 | } 22 | 23 | if errors.Is(err, articletag.InvalidSortRankError) { 24 | return model.InvalidSortRankProblem{Message: "outdated sort rankings"}, nil 25 | } 26 | 27 | if errors.Is(err, articleblock.VersionMismatchError) { 28 | return NewVersionMismatchProblem(), nil 29 | } 30 | 31 | if err != nil { 32 | r.env.Logger.Error("articletag.Service.MoveArticleTag", zap.Error(err)) 33 | 34 | return NewInternalErrorProblem(), nil 35 | } 36 | 37 | return model.ArticleTagMoveOk{ 38 | SortRank: movedArticleTag.SortRank, 39 | Version: movedArticleTag.Version, 40 | }, nil 41 | } 42 | -------------------------------------------------------------------------------- /internal/feature/articleblock/mapper.go: -------------------------------------------------------------------------------- 1 | package articleblock 2 | 3 | import ( 4 | "fmt" 5 | "github.com/acelot/articles/internal/gql/model" 6 | ) 7 | 8 | type ArticleBlockType string 9 | 10 | const ( 11 | ArticleBlockTypeHTML ArticleBlockType = "html" 12 | ArticleBlockTypeImage ArticleBlockType = "image" 13 | ) 14 | 15 | func MapOneToGqlModel(block ArticleBlock) (model.ArticleBlockInterface, error) { 16 | switch block.Type { 17 | case ArticleBlockTypeHTML: 18 | return mapBlockToGqlModelArticleBlockHTML(block) 19 | case ArticleBlockTypeImage: 20 | return mapBlockToGqlModelArticleBlockImage(block) 21 | default: 22 | return nil, fmt.Errorf(`cannot map block with unknown type "%s"`, block.Type) 23 | } 24 | } 25 | 26 | func MapManyToGqlModels(blocks []ArticleBlock) ([]model.ArticleBlockInterface, error) { 27 | items := make([]model.ArticleBlockInterface, len(blocks)) 28 | 29 | for i, entity := range blocks { 30 | model, err := MapOneToGqlModel(entity) 31 | if err != nil { 32 | return items, err 33 | } 34 | 35 | items[i] = model 36 | } 37 | 38 | return items, nil 39 | } 40 | 41 | func MapGqlArticleBlockTypeEnumToArticleBlockType(blockType model.ArticleBlockTypeEnum) (ArticleBlockType, error) { 42 | switch blockType { 43 | case model.ArticleBlockTypeEnumHTML: 44 | return ArticleBlockTypeHTML, nil 45 | case model.ArticleBlockTypeEnumImage: 46 | return ArticleBlockTypeImage, nil 47 | default: 48 | return "", fmt.Errorf(`unmapped ArticleBlockTypeEnum value "%s"`, blockType) 49 | } 50 | } -------------------------------------------------------------------------------- /internal/gql/resolver/articleblockmutation_create.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/articleblock" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *articleBlockMutationResolver) Create(ctx context.Context, obj *model.ArticleBlockMutation, input model.ArticleBlockCreateInput) (model.ArticleBlockCreateResult, error) { 16 | createdArticleBlock, err := r.env.Services.ArticleBlockService.CreateArticleBlock(input) 17 | 18 | if errors.Is(err, articleblock.ArticleNotFoundError) { 19 | return model.ArticleNotFoundProblem{Message: "article not found"}, nil 20 | } 21 | 22 | if errors.Is(err, articleblock.InvalidSortRankError) { 23 | return model.InvalidSortRankProblem{Message: "outdated sort rankings"}, nil 24 | } 25 | 26 | if err != nil { 27 | r.env.Logger.Error("articleblock.Service.CreateArticleBlock", zap.Error(err)) 28 | 29 | return NewInternalErrorProblem(), nil 30 | } 31 | 32 | gqlArticleBlock, err := articleblock.MapOneToGqlModel(*createdArticleBlock) 33 | if err != nil { 34 | r.env.Logger.Error("articleblock.MapOneToGqlModel", zap.Error(err)) 35 | 36 | return NewInternalErrorProblem(), nil 37 | } 38 | 39 | return model.ArticleBlockCreateOk{ 40 | ArticleBlock: gqlArticleBlock, 41 | }, nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/feature/articleblock/mapper_image.go: -------------------------------------------------------------------------------- 1 | package articleblock 2 | 3 | //goland:noinspection ALL 4 | import ( 5 | "fmt" 6 | gqlmodel "github.com/acelot/articles/internal/gql/model" 7 | "github.com/mitchellh/mapstructure" 8 | ) 9 | 10 | func mapBlockToGqlModelArticleBlockImage(block ArticleBlock) (*gqlmodel.ArticleBlockImage, error) { 11 | model := gqlmodel.ArticleBlockImage{ 12 | CreatedAt: block.CreatedAt, 13 | DeletedAt: block.DeletedAt, 14 | ID: block.ID, 15 | ModifiedAt: block.ModifiedAt, 16 | SortRank: block.SortRank, 17 | Version: block.Version, 18 | } 19 | 20 | var err error 21 | 22 | model.Data, err = unmarshalArticleBlockImageData(block.Data) 23 | if err != nil { 24 | return nil, fmt.Errorf("cannot map block data, possible data corruption: %v", err) 25 | } 26 | 27 | return &model, nil 28 | } 29 | 30 | func unmarshalArticleBlockImageData(data map[string]interface{}) (*gqlmodel.ArticleBlockImageData, error) { 31 | parsed := &gqlmodel.ArticleBlockImageData{} 32 | 33 | decoder, _ := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 34 | DecodeHook: mapstructure.TextUnmarshallerHookFunc(), 35 | Result: parsed, 36 | TagName: "json", 37 | }) 38 | 39 | err := decoder.Decode(data) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return parsed, nil 45 | } 46 | 47 | func marshalArticleBlockImageData(data gqlmodel.ArticleBlockImageDataInput) map[string]interface{} { 48 | result := map[string]interface {}{} 49 | 50 | _ = mapstructure.Decode(data, result) 51 | 52 | return result 53 | } 54 | -------------------------------------------------------------------------------- /internal/gql/resolver/imagemutation_upload.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/image" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *imageMutationResolver) Upload(ctx context.Context, obj *model.ImageMutation, input model.ImageUploadInput) (model.ImageUploadResult, error) { 16 | if input.File == nil { 17 | return nil, errors.New("file not uploaded") 18 | } 19 | 20 | createdImage, err := r.env.Services.ImageService.UploadImage(input.File) 21 | 22 | if errors.Is(err, image.NotRecognizedError) { 23 | return model.ImageNotRecognizedProblem{Message: "image not recognized"}, nil 24 | } 25 | 26 | if errors.Is(err, image.NotSupportedTypeError) { 27 | return model.ImageNotSupportedTypeProblem{ 28 | Message: "image type not supported; supported formats: jpeg, png, webp, avif", 29 | }, nil 30 | } 31 | 32 | if err != nil { 33 | r.env.Logger.Error("image.Service.UploadImage", zap.Error(err)) 34 | 35 | return NewInternalErrorProblem(), nil 36 | } 37 | 38 | gqlImage, err := image.MapOneToGqlModel(*createdImage) 39 | if err != nil { 40 | r.env.Logger.Error("image.MapOneToGqlModel", zap.Error(err)) 41 | 42 | return NewInternalErrorProblem(), nil 43 | } 44 | 45 | return model.ImageUploadOk{ 46 | Image: gqlImage, 47 | }, nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/feature/tag/loaderbyid.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "context" 5 | "github.com/acelot/articles/internal/go-pg/condition" 6 | "github.com/google/uuid" 7 | "time" 8 | ) 9 | 10 | type LoaderByIDKey struct { 11 | ID uuid.UUID 12 | } 13 | 14 | func NewConfiguredLoaderByID(repo *Repository, maxBatch int) *LoaderByID { 15 | return NewLoaderByID(LoaderByIDConfig{ 16 | Wait: 2 * time.Millisecond, 17 | MaxBatch: maxBatch, 18 | Fetch: func(keys []LoaderByIDKey) ([]*Tag, []error) { 19 | items := make([]*Tag, len(keys)) 20 | errors := make([]error, len(keys)) 21 | 22 | ids := getUniqueTagIDs(keys) 23 | 24 | tags, err := repo.Find(context.Background(), FindFilterIDAnyOf(ids), condition.Limit(len(ids))) 25 | if err != nil { 26 | for i := range keys { 27 | errors[i] = err 28 | } 29 | } 30 | 31 | groups := groupTagsByID(tags) 32 | for i, key := range keys { 33 | if t, ok := groups[key.ID]; ok { 34 | items[i] = &t 35 | } 36 | } 37 | 38 | return items, errors 39 | }, 40 | }) 41 | } 42 | 43 | func getUniqueTagIDs(keys []LoaderByIDKey) []uuid.UUID { 44 | mapping := make(map[uuid.UUID]bool) 45 | 46 | for _, key := range keys { 47 | mapping[key.ID] = true 48 | } 49 | 50 | ids := make([]uuid.UUID, len(mapping)) 51 | 52 | i := 0 53 | for key := range mapping { 54 | ids[i] = key 55 | i++ 56 | } 57 | 58 | return ids 59 | } 60 | 61 | func groupTagsByID(tags []Tag) map[uuid.UUID]Tag { 62 | groups := make(map[uuid.UUID]Tag) 63 | 64 | for _, t := range tags { 65 | groups[t.ID] = t 66 | } 67 | 68 | return groups 69 | } 70 | -------------------------------------------------------------------------------- /internal/gql/resolver/articletagmutation_create.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/articletag" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *articleTagMutationResolver) Create(ctx context.Context, obj *model.ArticleTagMutation, input model.ArticleTagCreateInput) (model.ArticleTagCreateResult, error) { 16 | createdArticleTag, err := r.env.Services.ArticleTagService.CreateArticleTag(input) 17 | 18 | if errors.Is(err, articletag.ArticleNotFoundError) { 19 | return model.ArticleNotFoundProblem{Message: "article not found"}, nil 20 | } 21 | 22 | if errors.Is(err, articletag.TagNotFoundError) { 23 | return model.TagNotFoundProblem{Message: "tag not found"}, nil 24 | } 25 | 26 | if errors.Is(err, articletag.DuplicateTagError) { 27 | return model.ArticleTagAlreadyExistsProblem{Message: "tag already exists in the article"}, nil 28 | } 29 | 30 | if errors.Is(err, articletag.InvalidSortRankError) { 31 | return model.InvalidSortRankProblem{Message: "outdated sort rankings"}, nil 32 | } 33 | 34 | if err != nil { 35 | r.env.Logger.Error("articletag.Service.CreateArticleTag", zap.Error(err)) 36 | 37 | return NewInternalErrorProblem(), nil 38 | } 39 | 40 | return model.ArticleTagCreateOk{ 41 | ArticleTag: articletag.MapOneToGqlModel(*createdArticleTag), 42 | }, nil 43 | } 44 | -------------------------------------------------------------------------------- /internal/feature/image/loaderbyid.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "github.com/acelot/articles/internal/go-pg/condition" 6 | "github.com/google/uuid" 7 | "time" 8 | ) 9 | 10 | type LoaderByIDKey struct { 11 | ID uuid.UUID 12 | } 13 | 14 | func NewConfiguredLoaderByID(repo *Repository, maxBatch int) *LoaderByID { 15 | return NewLoaderByID(LoaderByIDConfig{ 16 | Wait: 2 * time.Millisecond, 17 | MaxBatch: maxBatch, 18 | Fetch: func(keys []LoaderByIDKey) ([]*Image, []error) { 19 | items := make([]*Image, len(keys)) 20 | errors := make([]error, len(keys)) 21 | 22 | ids := getUniqueImageIDs(keys) 23 | 24 | images, err := repo.Find( 25 | context.Background(), 26 | FindFilterIDAnyOf(ids), 27 | FindOrderByCreatedAt(false), 28 | condition.Limit(len(ids)), 29 | ) 30 | if err != nil { 31 | for i := range keys { 32 | errors[i] = err 33 | } 34 | } 35 | 36 | groups := groupImagesByID(images) 37 | for i, key := range keys { 38 | if p, ok := groups[key.ID]; ok { 39 | items[i] = &p 40 | } 41 | } 42 | 43 | return items, errors 44 | }, 45 | }) 46 | } 47 | 48 | func getUniqueImageIDs(keys []LoaderByIDKey) []uuid.UUID { 49 | mapping := make(map[uuid.UUID]bool) 50 | 51 | for _, key := range keys { 52 | mapping[key.ID] = true 53 | } 54 | 55 | ids := make([]uuid.UUID, len(mapping)) 56 | 57 | i := 0 58 | for key := range mapping { 59 | ids[i] = key 60 | i++ 61 | } 62 | 63 | return ids 64 | } 65 | 66 | func groupImagesByID(images []Image) map[uuid.UUID]Image { 67 | groups := make(map[uuid.UUID]Image) 68 | 69 | for _, p := range images { 70 | groups[p.ID] = p 71 | } 72 | 73 | return groups 74 | } 75 | -------------------------------------------------------------------------------- /internal/feature/project/loaderbyid.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "context" 5 | "github.com/acelot/articles/internal/go-pg/condition" 6 | "github.com/google/uuid" 7 | "time" 8 | ) 9 | 10 | type LoaderByIDKey struct { 11 | ID uuid.UUID 12 | } 13 | 14 | func NewConfiguredLoaderByID(repo *Repository, maxBatch int) *LoaderByID { 15 | return NewLoaderByID(LoaderByIDConfig{ 16 | Wait: 2 * time.Millisecond, 17 | MaxBatch: maxBatch, 18 | Fetch: func(keys []LoaderByIDKey) ([]*Project, []error) { 19 | items := make([]*Project, len(keys)) 20 | errors := make([]error, len(keys)) 21 | 22 | ids := getUniqueProjectIDs(keys) 23 | 24 | projects, err := repo.Find( 25 | context.Background(), 26 | FindFilterIDAnyOf(ids), 27 | condition.Limit(len(ids)), 28 | FindOrderByCreatedAt(false), 29 | ) 30 | if err != nil { 31 | for i := range keys { 32 | errors[i] = err 33 | } 34 | } 35 | 36 | groups := groupProjectsByID(projects) 37 | for i, key := range keys { 38 | if p, ok := groups[key.ID]; ok { 39 | items[i] = &p 40 | } 41 | } 42 | 43 | return items, errors 44 | }, 45 | }) 46 | } 47 | 48 | func getUniqueProjectIDs(keys []LoaderByIDKey) []uuid.UUID { 49 | mapping := make(map[uuid.UUID]bool) 50 | 51 | for _, key := range keys { 52 | mapping[key.ID] = true 53 | } 54 | 55 | ids := make([]uuid.UUID, len(mapping)) 56 | 57 | i := 0 58 | for key := range mapping { 59 | ids[i] = key 60 | i++ 61 | } 62 | 63 | return ids 64 | } 65 | 66 | func groupProjectsByID(projects []Project) map[uuid.UUID]Project { 67 | groups := make(map[uuid.UUID]Project) 68 | 69 | for _, p := range projects { 70 | groups[p.ID] = p 71 | } 72 | 73 | return groups 74 | } 75 | -------------------------------------------------------------------------------- /internal/feature/articleblock/mapper_html.go: -------------------------------------------------------------------------------- 1 | package articleblock 2 | 3 | //goland:noinspection SpellCheckingInspection 4 | import ( 5 | "fmt" 6 | gqlmodel "github.com/acelot/articles/internal/gql/model" 7 | "github.com/mitchellh/mapstructure" 8 | ) 9 | 10 | func mapBlockToGqlModelArticleBlockHTML(block ArticleBlock) (*gqlmodel.ArticleBlockHTML, error) { 11 | model := gqlmodel.ArticleBlockHTML{ 12 | CreatedAt: block.CreatedAt, 13 | DeletedAt: block.DeletedAt, 14 | ID: block.ID, 15 | ModifiedAt: block.ModifiedAt, 16 | SortRank: block.SortRank, 17 | Version: block.Version, 18 | } 19 | 20 | var err error 21 | 22 | model.Data, err = unmarshalArticleBlockHTMLData(block.Data) 23 | if err != nil { 24 | return nil, fmt.Errorf("cannot map block data, possible data corruption: %v", err) 25 | } 26 | 27 | return &model, nil 28 | } 29 | 30 | func unmarshalArticleBlockHTMLData(data map[string]interface{}) (*gqlmodel.ArticleBlockHTMLData, error) { 31 | parsed := &gqlmodel.ArticleBlockHTMLData{} 32 | 33 | decoder, _ := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 34 | DecodeHook: mapstructure.TextUnmarshallerHookFunc(), 35 | Result: parsed, 36 | TagName: "json", 37 | }) 38 | 39 | err := decoder.Decode(data) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return parsed, nil 45 | } 46 | 47 | func marshalArticleBlockHTMLData(data gqlmodel.ArticleBlockHTMLDataInput) map[string]interface{} { 48 | result := map[string]interface {}{} 49 | 50 | decoder, _ := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ 51 | DecodeHook: mapstructure.TextUnmarshallerHookFunc(), 52 | Result: &result, 53 | TagName: "json", 54 | }) 55 | 56 | _ = decoder.Decode(data) 57 | 58 | return result 59 | } -------------------------------------------------------------------------------- /internal/gql/resolver/articleblockimage.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/app" 10 | "github.com/acelot/articles/internal/feature/image" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "github.com/acelot/articles/internal/gql/runtime" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *articleBlockImageDataResolver) Image(ctx context.Context, obj *model.ArticleBlockImageData) (model.ImageResolvingResult, error) { 17 | if obj.ImageID == nil { 18 | return model.ImageNotFoundProblem{Message: "image not set"}, nil 19 | } 20 | 21 | dataLoader := ctx.Value(app.DataLoadersContextKey).(*app.DataLoaders).ImageLoaderByID 22 | 23 | img, err := dataLoader.Load(image.LoaderByIDKey{ID: *obj.ImageID}) 24 | if err != nil { 25 | r.env.Logger.Error("image.LoaderByID.Load", zap.Error(err)) 26 | 27 | return NewInternalErrorProblem(), nil 28 | } 29 | 30 | if img == nil { 31 | return model.ImageNotFoundProblem{Message: "image not found"}, nil 32 | } 33 | 34 | gqlImage, err := image.MapOneToGqlModel(*img) 35 | if err != nil { 36 | r.env.Logger.Error("image.MapOneToGqlModel", zap.Error(err)) 37 | 38 | return NewInternalErrorProblem(), nil 39 | } 40 | 41 | return *gqlImage, nil 42 | } 43 | 44 | // ArticleBlockImageData returns runtime.ArticleBlockImageDataResolver implementation. 45 | func (r *Resolver) ArticleBlockImageData() runtime.ArticleBlockImageDataResolver { 46 | return &articleBlockImageDataResolver{r} 47 | } 48 | 49 | type articleBlockImageDataResolver struct{ *Resolver } 50 | -------------------------------------------------------------------------------- /internal/gql/resolver/articleblockmutation_update.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | 10 | "github.com/acelot/articles/internal/feature/articleblock" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | func (r *articleBlockMutationResolver) Update(ctx context.Context, obj *model.ArticleBlockMutation, input model.ArticleBlockUpdateInput) (model.ArticleBlockUpdateResult, error) { 16 | updatedArticleBlock, err := r.env.Services.ArticleBlockService.UpdateArticleBlock(input) 17 | 18 | if errors.Is(err, articleblock.ArticleNotFoundError) { 19 | return model.ArticleBlockNotFoundProblem{Message: "article block not found"}, nil 20 | } 21 | 22 | if errors.Is(err, articleblock.TypeMismatchError) { 23 | return model.ArticleBlockTypeMismatchProblem{ 24 | Message: "data input type of the article block doesn't match the block type of the updated block", 25 | }, nil 26 | } 27 | 28 | if errors.Is(err, articleblock.VersionMismatchError) { 29 | return NewVersionMismatchProblem(), nil 30 | } 31 | 32 | if err != nil { 33 | r.env.Logger.Error("articleblock.Service.UpdateArticleBlock", zap.Error(err)) 34 | 35 | return NewInternalErrorProblem(), nil 36 | } 37 | 38 | gqlArticleBlock, err := articleblock.MapOneToGqlModel(*updatedArticleBlock) 39 | if err != nil { 40 | r.env.Logger.Error("articleblock.MapOneToGqlModel", zap.Error(err)) 41 | 42 | return NewInternalErrorProblem(), nil 43 | } 44 | 45 | return model.ArticleBlockUpdateOk{ 46 | ArticleBlock: gqlArticleBlock, 47 | }, nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/feature/articletag/loaderbyarticleid.go: -------------------------------------------------------------------------------- 1 | package articletag 2 | 3 | import ( 4 | "context" 5 | "github.com/google/uuid" 6 | "time" 7 | ) 8 | 9 | type LoaderByArticleIDKey struct { 10 | ArticleID uuid.UUID 11 | } 12 | 13 | func NewConfiguredLoaderByArticleID(repo *Repository, maxBatch int) *LoaderByArticleID { 14 | return NewLoaderByArticleID(LoaderByArticleIDConfig{ 15 | Wait: 2 * time.Millisecond, 16 | MaxBatch: maxBatch, 17 | Fetch: func(keys []LoaderByArticleIDKey) ([][]ArticleTag, []error) { 18 | items := make([][]ArticleTag, len(keys)) 19 | errors := make([]error, len(keys)) 20 | 21 | ids := getUniqueArticleIDs(keys) 22 | 23 | articleTags, err := repo.Find( 24 | context.Background(), 25 | FindFilterArticleIDAnyOf(ids), 26 | FindOrderBySortRank(false), 27 | ) 28 | if err != nil { 29 | for index := range keys { 30 | errors[index] = err 31 | } 32 | } 33 | 34 | groups := groupArticleTagsByArticleID(articleTags) 35 | for i, key := range keys { 36 | if at, ok := groups[key.ArticleID]; ok { 37 | items[i] = at 38 | } 39 | } 40 | 41 | return items, errors 42 | }, 43 | }) 44 | } 45 | 46 | func getUniqueArticleIDs(keys []LoaderByArticleIDKey) []uuid.UUID { 47 | mapping := make(map[uuid.UUID]bool) 48 | 49 | for _, key := range keys { 50 | mapping[key.ArticleID] = true 51 | } 52 | 53 | ids := make([]uuid.UUID, len(mapping)) 54 | 55 | i := 0 56 | for key := range mapping { 57 | ids[i] = key 58 | i++ 59 | } 60 | 61 | return ids 62 | } 63 | 64 | func groupArticleTagsByArticleID(articleTags []ArticleTag) map[uuid.UUID][]ArticleTag { 65 | groups := make(map[uuid.UUID][]ArticleTag) 66 | 67 | for _, at := range articleTags { 68 | groups[at.ArticleID] = append(groups[at.ArticleID], at) 69 | } 70 | 71 | return groups 72 | } -------------------------------------------------------------------------------- /internal/gql/resolver/tagquery_find.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/99designs/gqlgen/graphql" 10 | "github.com/acelot/articles/internal/feature/tag" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "github.com/acelot/articles/internal/gql/runtime" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *tagFindListResolver) TotalCount(ctx context.Context, obj *model.TagFindList, estimate uint) (model.TotalCountResolvingResult, error) { 17 | filter := graphql.GetFieldContext(ctx).Parent.Args["filter"].(*model.TagFindFilterInput) 18 | 19 | count, err := r.env.Services.TagService.CountTags(filter, estimate) 20 | if err != nil { 21 | r.env.Logger.Error("tag.Service.CountTags", zap.Error(err)) 22 | 23 | return NewInternalErrorProblem(), nil 24 | } 25 | 26 | return model.TotalCount{ 27 | Value: count, 28 | }, nil 29 | } 30 | 31 | func (r *tagQueryResolver) Find(ctx context.Context, obj *model.TagQuery, filter *model.TagFindFilterInput, sort model.TagFindSortEnum, pageSize uint, pageNumber uint) (model.TagFindResult, error) { 32 | tags, err := r.env.Services.TagService.FindTags(filter, sort, pageSize, pageNumber) 33 | if err != nil { 34 | r.env.Logger.Error("tag.Service.FindTags", zap.Error(err)) 35 | 36 | return NewInternalErrorProblem(), nil 37 | } 38 | 39 | return model.TagFindList{ 40 | Items: tag.MapManyToGqlModels(tags), 41 | }, nil 42 | } 43 | 44 | // TagFindList returns runtime.TagFindListResolver implementation. 45 | func (r *Resolver) TagFindList() runtime.TagFindListResolver { return &tagFindListResolver{r} } 46 | 47 | type tagFindListResolver struct{ *Resolver } 48 | -------------------------------------------------------------------------------- /internal/gql/resolver/articleblocksubscription.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | "time" 9 | 10 | "github.com/acelot/articles/internal/feature/articleblock" 11 | "github.com/acelot/articles/internal/go-pg/condition" 12 | "github.com/acelot/articles/internal/gql/model" 13 | "github.com/google/uuid" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | func (r *subscriptionResolver) ArticleBlockCreated(ctx context.Context, articleID uuid.UUID) (<-chan model.ArticleBlockInterface, error) { 18 | isSocketClosed := false 19 | resultChan := make(chan model.ArticleBlockInterface, 1) 20 | 21 | go func() { 22 | <-ctx.Done() 23 | 24 | r.env.Logger.Debug("websocket is closed") 25 | 26 | isSocketClosed = true 27 | }() 28 | 29 | go func() { 30 | Loop: 31 | for { 32 | if isSocketClosed { 33 | break Loop 34 | } 35 | 36 | lastBlocks, err := r.env.Repositories.ArticleBlockRepository.Find( 37 | context.Background(), 38 | articleblock.FindFilterArticleIDAnyOf{articleID}, 39 | articleblock.FindOrderByCreatedAt(true), 40 | condition.Limit(1), 41 | ) 42 | 43 | if err != nil { 44 | r.env.Logger.Error("articleblock.Repository.Find", zap.Error(err)) 45 | 46 | continue 47 | } 48 | 49 | if len(lastBlocks) == 0 { 50 | continue 51 | } 52 | 53 | gqlArticleBlock, err := articleblock.MapOneToGqlModel(lastBlocks[0]) 54 | if err != nil { 55 | r.env.Logger.Error("articleblock.MapOneToGqlModel", zap.Error(err)) 56 | 57 | continue 58 | } 59 | 60 | resultChan <- gqlArticleBlock 61 | 62 | time.Sleep(5 * time.Second) 63 | } 64 | }() 65 | 66 | return resultChan, nil 67 | } 68 | -------------------------------------------------------------------------------- /internal/gql/resolver/projectquery_find.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/99designs/gqlgen/graphql" 10 | "github.com/acelot/articles/internal/feature/project" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "github.com/acelot/articles/internal/gql/runtime" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *projectFindListResolver) TotalCount(ctx context.Context, obj *model.ProjectFindList) (model.TotalCountResolvingResult, error) { 17 | filter := graphql.GetFieldContext(ctx).Parent.Args["filter"].(*model.ProjectFindFilterInput) 18 | 19 | count, err := r.env.Services.ProjectService.CountProjects(filter) 20 | if err != nil { 21 | r.env.Logger.Error("project.Service.CountProjects", zap.Error(err)) 22 | 23 | return NewInternalErrorProblem(), nil 24 | } 25 | 26 | return model.TotalCount{ 27 | Value: count, 28 | }, nil 29 | } 30 | 31 | func (r *projectQueryResolver) Find(ctx context.Context, obj *model.ProjectQuery, filter *model.ProjectFindFilterInput, sort model.ProjectFindSortEnum, pageSize uint, pageNumber uint) (model.ProjectFindResult, error) { 32 | projects, err := r.env.Services.ProjectService.FindProjects(filter, sort, pageSize, pageNumber) 33 | if err != nil { 34 | r.env.Logger.Error("project.Service.FindProjects", zap.Error(err)) 35 | 36 | return NewInternalErrorProblem(), nil 37 | } 38 | 39 | return model.ProjectFindList{ 40 | Items: project.MapManyToGqlModels(projects), 41 | }, nil 42 | } 43 | 44 | // ProjectFindList returns runtime.ProjectFindListResolver implementation. 45 | func (r *Resolver) ProjectFindList() runtime.ProjectFindListResolver { 46 | return &projectFindListResolver{r} 47 | } 48 | 49 | type projectFindListResolver struct{ *Resolver } 50 | -------------------------------------------------------------------------------- /internal/feature/dbmigration/repository.go: -------------------------------------------------------------------------------- 1 | package dbmigration 2 | 3 | import ( 4 | "context" 5 | "github.com/acelot/articles/internal/go-pg/condition" 6 | "github.com/go-pg/pg/v10" 7 | "github.com/go-pg/pg/v10/orm" 8 | ) 9 | 10 | type Repository struct { 11 | db *pg.DB 12 | } 13 | 14 | type FindFilterNameAnyOf []string 15 | type FindFilterIsAppliedOnly bool 16 | type FindOrderByName bool 17 | type FindOrderByAppliedAt bool 18 | 19 | func (names FindFilterNameAnyOf) Apply(query *pg.Query) { 20 | query.Where(`name = ANY(?)`, pg.Array(names)) 21 | } 22 | 23 | func (isAppliedOnly FindFilterIsAppliedOnly) Apply(query *pg.Query) { 24 | if isAppliedOnly { 25 | query.Where(`applied_at IS NOT NULL`) 26 | } 27 | } 28 | 29 | func (isDesc FindOrderByName) Apply(query *pg.Query) { 30 | dir := "ASC" 31 | if isDesc { 32 | dir = "DESC" 33 | } 34 | 35 | query.Order("name " + dir) 36 | } 37 | 38 | func (isDesc FindOrderByAppliedAt) Apply(query *pg.Query) { 39 | dir := "ASC" 40 | if isDesc { 41 | dir = "DESC" 42 | } 43 | 44 | query.Order("applied_at " + dir) 45 | } 46 | 47 | func NewRepository(db *pg.DB) *Repository { 48 | return &Repository{ 49 | db, 50 | } 51 | } 52 | 53 | func (r *Repository) EnsureTable() error { 54 | return r.db.Model((*DBMigration)(nil)).CreateTable( 55 | &orm.CreateTableOptions{ 56 | IfNotExists: true, 57 | }, 58 | ) 59 | } 60 | 61 | func (r *Repository) Find(ctx context.Context, conditions ...condition.Condition) ([]DBMigration, error) { 62 | var items []DBMigration 63 | 64 | query := r.db.ModelContext(ctx, &items) 65 | 66 | condition.Apply(query, conditions...) 67 | 68 | err := query.Select() 69 | 70 | return items, err 71 | } 72 | 73 | func (r *Repository) Create(m *DBMigration) error { 74 | _, err := r.db.Model(m).Insert() 75 | 76 | return err 77 | } 78 | 79 | func (r *Repository) Update(m *DBMigration) error { 80 | _, err := r.db.Model(m).WherePK().Update() 81 | 82 | return err 83 | } 84 | -------------------------------------------------------------------------------- /internal/gql/resolver/articlequery_find.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/99designs/gqlgen/graphql" 10 | "github.com/acelot/articles/internal/feature/article" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "github.com/acelot/articles/internal/gql/runtime" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *articleFindListResolver) TotalCount(ctx context.Context, obj *model.ArticleFindList, estimate uint) (model.TotalCountResolvingResult, error) { 17 | filter := graphql.GetFieldContext(ctx).Parent.Args["filter"].(*model.ArticleFindFilterInput) 18 | 19 | count, err := r.env.Services.ArticleService.CountArticles(filter, estimate) 20 | if err != nil { 21 | r.env.Logger.Error("article.Service.CountArticles", zap.Error(err)) 22 | 23 | return NewInternalErrorProblem(), nil 24 | } 25 | 26 | return model.TotalCount{ 27 | Value: count, 28 | }, nil 29 | } 30 | 31 | func (r *articleQueryResolver) Find(ctx context.Context, obj *model.ArticleQuery, filter *model.ArticleFindFilterInput, sort model.ArticleFindSortEnum, pageSize uint, pageNumber uint) (model.ArticleFindResult, error) { 32 | articles, err := r.env.Services.ArticleService.FindArticles(filter, sort, pageSize, pageNumber) 33 | if err != nil { 34 | r.env.Logger.Error("article.Service.FindArticles", zap.Error(err)) 35 | 36 | return NewInternalErrorProblem(), nil 37 | } 38 | 39 | return model.ArticleFindList{ 40 | Items: article.MapManyToGqlModels(articles), 41 | }, nil 42 | } 43 | 44 | // ArticleFindList returns runtime.ArticleFindListResolver implementation. 45 | func (r *Resolver) ArticleFindList() runtime.ArticleFindListResolver { 46 | return &articleFindListResolver{r} 47 | } 48 | 49 | type articleFindListResolver struct{ *Resolver } 50 | -------------------------------------------------------------------------------- /internal/app/db.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "github.com/go-pg/pg/v10" 6 | "go.uber.org/zap" 7 | ) 8 | 9 | type Databases struct { 10 | PrimaryDB *pg.DB 11 | SecondaryDB *pg.DB 12 | } 13 | 14 | type loggerHook struct { 15 | instance string 16 | logger *zap.Logger 17 | } 18 | 19 | func NewDatabases(primaryDsn string, secondaryDsn string, logger *zap.Logger) (*Databases, error) { 20 | primaryDB, err := newDb(primaryDsn) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | primaryDB.AddQueryHook(loggerHook{"primary DB",logger}) 26 | 27 | secondaryDB, err := getSecondaryDb(primaryDB, secondaryDsn) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | if secondaryDB != primaryDB { 33 | secondaryDB.AddQueryHook(loggerHook{"secondary DB",logger}) 34 | } 35 | 36 | return &Databases{ 37 | PrimaryDB: primaryDB, 38 | SecondaryDB: secondaryDB, 39 | }, nil 40 | } 41 | 42 | func (databases *Databases) Close() error { 43 | if err := databases.SecondaryDB.Close(); err != nil { 44 | return err 45 | } 46 | 47 | if err := databases.PrimaryDB.Close(); err != nil { 48 | return err 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func newDb(dsn string) (*pg.DB, error) { 55 | opts, err := pg.ParseURL(dsn) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | db := pg.Connect(opts) 61 | 62 | return db, nil 63 | } 64 | 65 | func getSecondaryDb(primaryDb *pg.DB, secondaryDsn string) (*pg.DB, error) { 66 | if secondaryDsn == "" { 67 | return primaryDb, nil 68 | } 69 | 70 | return newDb(secondaryDsn) 71 | } 72 | 73 | func (h loggerHook) BeforeQuery(c context.Context, q *pg.QueryEvent) (context.Context, error) { 74 | query, err := q.FormattedQuery() 75 | 76 | if err == nil { 77 | h.logger.Debug(h.instance, zap.ByteString("query", query)) 78 | } 79 | 80 | return c, nil 81 | } 82 | 83 | func (h loggerHook) AfterQuery(c context.Context, q *pg.QueryEvent) error { 84 | return nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/app/dataloaders.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/acelot/articles/internal/feature/article" 5 | "github.com/acelot/articles/internal/feature/articleblock" 6 | "github.com/acelot/articles/internal/feature/articletag" 7 | "github.com/acelot/articles/internal/feature/image" 8 | "github.com/acelot/articles/internal/feature/project" 9 | "github.com/acelot/articles/internal/feature/tag" 10 | ) 11 | 12 | const ( 13 | articleLoaderByIDMaxBatch int = 100 14 | articleBlockLoaderByArticleIDMaxBatch int = 10 15 | articleTagLoaderByArticleIDMaxBatch int = 10 16 | imageLoaderByIDMaxBatch int = 100 17 | projectLoaderByIDMaxBatch int = 10 18 | tagLoaderByIDMaxBatch int = 100 19 | ) 20 | 21 | type DataLoaders struct { 22 | ArticleLoaderByID *article.LoaderByID 23 | ArticleBlockLoaderByArticleID *articleblock.LoaderByArticleID 24 | ArticleTagLoaderByArticleID *articletag.LoaderByArticleID 25 | ImageLoaderByID *image.LoaderByID 26 | ProjectLoaderByID *project.LoaderByID 27 | TagLoaderByID *tag.LoaderByID 28 | } 29 | 30 | func NewDataLoaders(repos *Repositories) *DataLoaders { 31 | return &DataLoaders{ 32 | ArticleLoaderByID: article.NewConfiguredLoaderByID(repos.ArticleRepository, articleLoaderByIDMaxBatch), 33 | ArticleBlockLoaderByArticleID: articleblock.NewConfiguredLoaderByArticleID(repos.ArticleBlockRepository, articleBlockLoaderByArticleIDMaxBatch), 34 | ArticleTagLoaderByArticleID: articletag.NewConfiguredLoaderByArticleID(repos.ArticleTagRepository, articleTagLoaderByArticleIDMaxBatch), 35 | ImageLoaderByID: image.NewConfiguredLoaderByID(repos.ImageRepository, imageLoaderByIDMaxBatch), 36 | ProjectLoaderByID: project.NewConfiguredLoaderByID(repos.ProjectRepository, projectLoaderByIDMaxBatch), 37 | TagLoaderByID: tag.NewConfiguredLoaderByID(repos.TagRepository, tagLoaderByIDMaxBatch), 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/feature/articleblock/loaderbyarticleid.go: -------------------------------------------------------------------------------- 1 | package articleblock 2 | 3 | import ( 4 | "context" 5 | "github.com/acelot/articles/internal/go-pg/condition" 6 | "github.com/google/uuid" 7 | "time" 8 | ) 9 | 10 | type LoaderByArticleIDRepository interface { 11 | Find(ctx context.Context, conditions ...condition.Condition) ([]ArticleBlock, error) 12 | } 13 | 14 | type LoaderByArticleIDKey struct { 15 | ArticleID uuid.UUID 16 | } 17 | 18 | func NewConfiguredLoaderByArticleID(repo LoaderByArticleIDRepository, maxBatch int) *LoaderByArticleID { 19 | return NewLoaderByArticleID(LoaderByArticleIDConfig{ 20 | Wait: 2 * time.Millisecond, 21 | MaxBatch: maxBatch, 22 | Fetch: func(keys []LoaderByArticleIDKey) ([][]ArticleBlock, []error) { 23 | items := make([][]ArticleBlock, len(keys)) 24 | errors := make([]error, len(keys)) 25 | 26 | ids := getUniqueArticleIDs(keys) 27 | 28 | blocks, err := repo.Find( 29 | context.Background(), 30 | FindFilterArticleIDAnyOf(ids), 31 | FindOrderBySortRank(false), 32 | ) 33 | if err != nil { 34 | for index := range keys { 35 | errors[index] = err 36 | } 37 | } 38 | 39 | groups := groupBlocksByArticleID(blocks) 40 | for i, key := range keys { 41 | if b, ok := groups[key.ArticleID]; ok { 42 | items[i] = b 43 | } 44 | } 45 | 46 | return items, errors 47 | }, 48 | }) 49 | } 50 | 51 | func getUniqueArticleIDs(keys []LoaderByArticleIDKey) []uuid.UUID { 52 | mapping := make(map[uuid.UUID]bool) 53 | 54 | for _, key := range keys { 55 | mapping[key.ArticleID] = true 56 | } 57 | 58 | ids := make([]uuid.UUID, len(mapping)) 59 | 60 | i := 0 61 | for key := range mapping { 62 | ids[i] = key 63 | i++ 64 | } 65 | 66 | return ids 67 | } 68 | 69 | func groupBlocksByArticleID(blocks []ArticleBlock) map[uuid.UUID][]ArticleBlock { 70 | groups := make(map[uuid.UUID][]ArticleBlock) 71 | 72 | for _, b := range blocks { 73 | groups[b.ArticleID] = append(groups[b.ArticleID], b) 74 | } 75 | 76 | return groups 77 | } -------------------------------------------------------------------------------- /internal/feature/article/loaderbyid.go: -------------------------------------------------------------------------------- 1 | package article 2 | 3 | import ( 4 | "context" 5 | "github.com/acelot/articles/internal/go-pg/condition" 6 | "github.com/google/uuid" 7 | "time" 8 | ) 9 | 10 | type LoaderByIDRepository interface { 11 | Find(ctx context.Context, conditions ...condition.Condition) ([]Article, error) 12 | } 13 | 14 | type LoaderByIDKey struct { 15 | ID uuid.UUID 16 | } 17 | 18 | func NewConfiguredLoaderByID(repo LoaderByIDRepository, maxBatch int) *LoaderByID { 19 | return NewLoaderByID(LoaderByIDConfig{ 20 | Wait: 2 * time.Millisecond, 21 | MaxBatch: maxBatch, 22 | Fetch: func(keys []LoaderByIDKey) ([]*Article, []error) { 23 | items := make([]*Article, len(keys)) 24 | errors := make([]error, len(keys)) 25 | 26 | ctx, cancel := context.WithTimeout( 27 | context.Background(), 28 | 50*time.Millisecond*time.Duration(len(keys)), 29 | ) 30 | defer cancel() 31 | 32 | ids := getUniqueArticleIDs(keys) 33 | 34 | articles, err := repo.Find( 35 | ctx, 36 | FindFilterIDAnyOf(ids), 37 | FindOrderByCreatedAt(false), 38 | condition.Limit(len(ids)), 39 | ) 40 | if err != nil { 41 | for i := range keys { 42 | errors[i] = err 43 | } 44 | } 45 | 46 | groups := groupArticlesByID(articles) 47 | for i, key := range keys { 48 | if a, ok := groups[key.ID]; ok { 49 | items[i] = &a 50 | } 51 | } 52 | 53 | return items, errors 54 | }, 55 | }) 56 | } 57 | 58 | func getUniqueArticleIDs(keys []LoaderByIDKey) []uuid.UUID { 59 | mapping := make(map[uuid.UUID]bool) 60 | 61 | for _, key := range keys { 62 | mapping[key.ID] = true 63 | } 64 | 65 | ids := make([]uuid.UUID, len(mapping)) 66 | 67 | i := 0 68 | for key := range mapping { 69 | ids[i] = key 70 | i++ 71 | } 72 | 73 | return ids 74 | } 75 | 76 | func groupArticlesByID(articles []Article) map[uuid.UUID]Article { 77 | groups := make(map[uuid.UUID]Article) 78 | 79 | for _, a := range articles { 80 | groups[a.ID] = a 81 | } 82 | 83 | return groups 84 | } 85 | -------------------------------------------------------------------------------- /internal/gql/resolver/imagequery_find.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/99designs/gqlgen/graphql" 10 | "github.com/acelot/articles/internal/feature/image" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "github.com/acelot/articles/internal/gql/runtime" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *imageFindListResolver) TotalCount(ctx context.Context, obj *model.ImageFindList, estimate uint) (model.TotalCountResolvingResult, error) { 17 | filter := graphql.GetFieldContext(ctx).Parent.Args["filter"].(*model.ImageFindFilterInput) 18 | 19 | count, err := r.env.Services.ImageService.CountImages(filter, estimate) 20 | 21 | if err != nil { 22 | r.env.Logger.Error("image.Service.CountImages", zap.Error(err)) 23 | 24 | return NewInternalErrorProblem(), nil 25 | } 26 | 27 | return model.TotalCount{ 28 | Value: count, 29 | }, nil 30 | } 31 | 32 | func (r *imageQueryResolver) Find(ctx context.Context, obj *model.ImageQuery, filter *model.ImageFindFilterInput, sort model.ImageFindSortEnum, pageSize uint, pageNumber uint) (model.ImageFindResult, error) { 33 | images, err := r.env.Services.ImageService.FindImages(filter, sort, pageSize, pageNumber) 34 | if err != nil { 35 | r.env.Logger.Error("image.Repository.Find", zap.Error(err)) 36 | 37 | return NewInternalErrorProblem(), nil 38 | } 39 | 40 | gqlImages, err := image.MapManyToGqlModels(images) 41 | if err != nil { 42 | r.env.Logger.Error("image.MapManyToGqlModels", zap.Error(err)) 43 | 44 | return NewInternalErrorProblem(), nil 45 | } 46 | 47 | return model.ImageFindList{ 48 | Items: gqlImages, 49 | }, nil 50 | } 51 | 52 | // ImageFindList returns runtime.ImageFindListResolver implementation. 53 | func (r *Resolver) ImageFindList() runtime.ImageFindListResolver { return &imageFindListResolver{r} } 54 | 55 | type imageFindListResolver struct{ *Resolver } 56 | -------------------------------------------------------------------------------- /internal/gql/resolver/articleblockquery_find.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/99designs/gqlgen/graphql" 10 | "github.com/acelot/articles/internal/feature/articleblock" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "github.com/acelot/articles/internal/gql/runtime" 13 | "go.uber.org/zap" 14 | ) 15 | 16 | func (r *articleBlockFindListResolver) TotalCount(ctx context.Context, obj *model.ArticleBlockFindList, estimate uint) (model.TotalCountResolvingResult, error) { 17 | filter := graphql.GetFieldContext(ctx).Parent.Args["filter"].(*model.ArticleBlockFindFilterInput) 18 | 19 | count, err := r.env.Services.ArticleBlockService.CountArticleBlocks(filter, estimate) 20 | if err != nil { 21 | r.env.Logger.Error("articleblock.Service.CountArticleBlocks", zap.Error(err)) 22 | 23 | return NewInternalErrorProblem(), nil 24 | } 25 | 26 | return model.TotalCount{ 27 | Value: count, 28 | }, nil 29 | } 30 | 31 | func (r *articleBlockQueryResolver) Find(ctx context.Context, obj *model.ArticleBlockQuery, filter *model.ArticleBlockFindFilterInput, sort model.ArticleBlockFindSortEnum, pageSize uint, pageNumber uint) (model.ArticleBlockFindResult, error) { 32 | articleBlocks, err := r.env.Services.ArticleBlockService.FindArticleBlocks(filter, sort, pageSize, pageNumber) 33 | if err != nil { 34 | r.env.Logger.Error("articleblock.Service.FindArticleBlocks", zap.Error(err)) 35 | 36 | return NewInternalErrorProblem(), nil 37 | } 38 | 39 | mapped, err := articleblock.MapManyToGqlModels(articleBlocks) 40 | if err != nil { 41 | r.env.Logger.Error("articleblock.MapManyToGqlModels", zap.Error(err)) 42 | 43 | return NewInternalErrorProblem(), nil 44 | } 45 | 46 | return model.ArticleBlockFindList{ 47 | Items: mapped, 48 | }, nil 49 | } 50 | 51 | // ArticleBlockFindList returns runtime.ArticleBlockFindListResolver implementation. 52 | func (r *Resolver) ArticleBlockFindList() runtime.ArticleBlockFindListResolver { 53 | return &articleBlockFindListResolver{r} 54 | } 55 | 56 | type articleBlockFindListResolver struct{ *Resolver } 57 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: help 2 | 3 | .PHONY: help 4 | help: ## Show help 5 | @echo 'usage: make [target] ...' 6 | @echo '' 7 | @echo 'targets:' 8 | @egrep '^(.+)\:\ .*##\ (.+)' ${MAKEFILE_LIST} | sed 's/:.*##/#/' | column -t -c 2 -s '#' 9 | 10 | .PHONY: tools 11 | tools: ## Install required tools 12 | go get -u github.com/99designs/gqlgen@v0.13.0 13 | go get -u github.com/vektah/dataloaden@v0.3.0 14 | go get -u golang.org/x/lint/golint 15 | 16 | .PHONY: clean 17 | clean: ## Run `go clean` 18 | go clean 19 | 20 | .PHONY: fmt 21 | fmt: ## Run `go fmt` 22 | go fmt ./... 23 | 24 | .PHONY: lint 25 | lint: ## Run `go lint` 26 | golint ./... 27 | 28 | .PHONY: test 29 | test: generate ## Run `go test` 30 | go test -v ./... -short 31 | 32 | .PHONY: generate-gqlgen 33 | generate-gqlgen: ## Generate GraphQL models and resolvers 34 | gqlgen generate 35 | 36 | .PHONY: generate-dataloaders 37 | generate-dataloaders: ## Generate dataloaders 38 | (cd internal/feature/article \ 39 | && dataloaden LoaderByID LoaderByIDKey \*github.com/acelot/articles/pkg/feature/article.Article) 40 | (cd internal/feature/articleblock \ 41 | && dataloaden LoaderByArticleID LoaderByArticleIDKey \[\]github.com/acelot/articles/pkg/feature/articleblock.ArticleBlock) 42 | (cd internal/feature/articletag \ 43 | && dataloaden LoaderByArticleID LoaderByArticleIDKey \[\]github.com/acelot/articles/pkg/feature/articletag.ArticleTag) 44 | (cd internal/feature/image \ 45 | && dataloaden LoaderByID LoaderByIDKey \*github.com/acelot/articles/pkg/feature/image.Image) 46 | (cd internal/feature/project \ 47 | && dataloaden LoaderByID LoaderByIDKey \*github.com/acelot/articles/pkg/feature/project.Project) 48 | (cd internal/feature/tag \ 49 | && dataloaden LoaderByID LoaderByIDKey \*github.com/acelot/articles/pkg/feature/tag.Tag) 50 | 51 | .PHONY: generate 52 | generate: generate-gqlgen generate-dataloaders ## Generate all 53 | go generate ./... 54 | 55 | .PHONY: build-app 56 | build-app: generate ## Build application 57 | CGO_CFLAGS_ALLOW=-Xpreprocessor go build -o app cmd/app/main.go 58 | 59 | .PHONY: build-migration 60 | build-migration: ## Build migration command 61 | CGO_CFLAGS_ALLOW=-Xpreprocessor go build -o migration cmd/migration/main.go 62 | 63 | .PHONY: build 64 | build: generate build-app build-migration ## Build all 65 | -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/acelot/articles/internal/app" 6 | "github.com/acelot/articles/internal/http/handler" 7 | "github.com/docopt/docopt-go" 8 | "go.uber.org/zap" 9 | "log" 10 | "net/http" 11 | "os" 12 | "os/signal" 13 | "syscall" 14 | ) 15 | 16 | const usage = `Application 17 | 18 | Usage: 19 | app [options] 20 | 21 | Options: 22 | -h --help Show this screen. 23 | -l --listen=
Server listen address [default: 0.0.0.0:80]. 24 | --secondary-db= Secondary PostgreSQL connection string. If omitted primary database is used. 25 | --image-storage= Images S3 storage URI [default: http://minioadmin:minioadmin@localhost:9000/images]. 26 | --log-level= Logging level [default: debug]. 27 | --shutdown-timeout= Shutdown timeout in seconds [default: 15].` 28 | 29 | func main() { 30 | stderr := log.New(os.Stderr, "", 0) 31 | 32 | args, err := parseArgs() 33 | if err != nil { 34 | stderr.Fatalf("cannot parse args: %v", err) 35 | } 36 | 37 | env, err := app.NewEnv(args) 38 | if err != nil { 39 | stderr.Fatalf("cannot create environment: %v", err) 40 | } 41 | 42 | env.Logger.Info("app started") 43 | 44 | server := http.Server{ 45 | Addr: args.ListenAddress, 46 | Handler: handler.NewAppHandler(env), 47 | } 48 | 49 | go func() { 50 | if err := server.ListenAndServe(); err != http.ErrServerClosed { 51 | env.Logger.Fatal("app stopped due error", zap.Error(err)) 52 | } 53 | 54 | env.Logger.Info("app stopped gracefully") 55 | }() 56 | 57 | interrupt := make(chan os.Signal, 1) 58 | signal.Notify(interrupt, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) 59 | 60 | <-interrupt 61 | 62 | env.Logger.Warn("app interruption signal received") 63 | 64 | ctx, cancel := context.WithTimeout(context.Background(), args.ShutdownTimeout) 65 | defer cancel() 66 | 67 | if err := server.Shutdown(ctx); err != nil { 68 | env.Logger.Fatal("app shutdown failed", zap.Error(err)) 69 | } 70 | 71 | if err := env.Close(); err != nil { 72 | env.Logger.Fatal("app environment closing failed", zap.Error(err)) 73 | } 74 | } 75 | 76 | func parseArgs() (*app.Args, error) { 77 | opts, err := docopt.ParseDoc(usage) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | args, err := app.NewArgs(&opts) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return args, nil 88 | } 89 | -------------------------------------------------------------------------------- /migrations/0001.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | 3 | CREATE TABLE project 4 | ( 5 | created_at timestamptz NOT NULL, 6 | deleted_at timestamptz, 7 | id uuid PRIMARY KEY, 8 | name text NOT NULL UNIQUE, 9 | modified_at timestamptz NOT NULL, 10 | version int NOT NULL 11 | ); 12 | 13 | CREATE TABLE article 14 | ( 15 | cover_image_id uuid, 16 | created_at timestamptz NOT NULL, 17 | deleted_at timestamptz, 18 | id uuid PRIMARY KEY, 19 | modified_at timestamptz NOT NULL, 20 | project_id uuid NOT NULL, 21 | title text NOT NULL, 22 | version int NOT NULL, 23 | 24 | FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE 25 | ); 26 | 27 | CREATE TABLE article_block 28 | ( 29 | article_id uuid NOT NULL, 30 | created_at timestamptz NOT NULL, 31 | data jsonb NOT NULL, 32 | deleted_at timestamptz, 33 | id uuid PRIMARY KEY, 34 | modified_at timestamptz NOT NULL, 35 | sort_rank text NOT NULL, 36 | type text NOT NULL, 37 | version int NOT NULL, 38 | 39 | UNIQUE (article_id, sort_rank), 40 | FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE 41 | ); 42 | 43 | CREATE TABLE tag 44 | ( 45 | created_at timestamptz NOT NULL, 46 | deleted_at timestamptz, 47 | id uuid PRIMARY KEY, 48 | name text NOT NULL UNIQUE, 49 | modified_at timestamptz NOT NULL, 50 | version int NOT NULL 51 | ); 52 | 53 | CREATE TABLE article_tag 54 | ( 55 | article_id uuid NOT NULL, 56 | created_at timestamptz NOT NULL, 57 | id uuid PRIMARY KEY, 58 | modified_at timestamptz NOT NULL, 59 | sort_rank text NOT NULL UNIQUE, 60 | tag_id uuid NOT NULL, 61 | version int NOT NULL, 62 | 63 | UNIQUE (article_id, tag_id), 64 | UNIQUE (article_id, sort_rank), 65 | FOREIGN KEY (article_id) REFERENCES article (id) ON DELETE CASCADE, 66 | FOREIGN KEY (tag_id) REFERENCES tag (id) ON DELETE CASCADE 67 | ); 68 | 69 | CREATE TABLE image 70 | ( 71 | created_at timestamptz NOT NULL, 72 | deleted_at timestamptz, 73 | type text NOT NULL, 74 | height int NOT NULL, 75 | id uuid PRIMARY KEY, 76 | modified_at timestamptz NOT NULL, 77 | version int NOT NULL, 78 | width int NOT NULL 79 | ); -------------------------------------------------------------------------------- /internal/feature/articletag/service.go: -------------------------------------------------------------------------------- 1 | package articletag 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/acelot/articles/internal/gql/model" 7 | "github.com/google/uuid" 8 | "github.com/xissy/lexorank" 9 | "time" 10 | ) 11 | 12 | var ArticleNotFoundError = errors.New("article not found error") 13 | var DuplicateTagError = errors.New("tag not found error") 14 | var InvalidSortRankError = errors.New("invalid sort rank error") 15 | var NotFoundError = errors.New("article tag not found error") 16 | var TagNotFoundError = errors.New("tag not found error") 17 | var VersionMismatchError = errors.New("version mismatch error") 18 | 19 | type Service struct { 20 | repository *Repository 21 | } 22 | 23 | func NewService(repository *Repository) *Service { 24 | return &Service{repository} 25 | } 26 | 27 | func (s *Service) CreateArticleTag(input model.ArticleTagCreateInput) (*ArticleTag, error) { 28 | newSortRank, _ := lexorank.Rank(input.SortRank.Prev, input.SortRank.Next) 29 | 30 | entity := ArticleTag{ 31 | ArticleID: input.ArticleID, 32 | CreatedAt: time.Now(), 33 | ID: uuid.New(), 34 | ModifiedAt: time.Now(), 35 | SortRank: newSortRank, 36 | TagID: input.TagID, 37 | Version: 0, 38 | } 39 | 40 | err := s.repository.Insert(context.Background(), entity) 41 | 42 | if errors.Is(err, ArticleIDConstraintError) { 43 | return nil, ArticleNotFoundError 44 | } 45 | 46 | if errors.Is(err, ArticleIDTagIDConstraintError) { 47 | return nil, DuplicateTagError 48 | } 49 | 50 | if errors.Is(err, ArticleIDSortRankConstraintError) { 51 | return nil, InvalidSortRankError 52 | } 53 | 54 | if errors.Is(err, TagIDConstraintError) { 55 | return nil, TagNotFoundError 56 | } 57 | 58 | return &entity, nil 59 | } 60 | 61 | func (s *Service) MoveArticleTag(input model.ArticleTagMoveInput) (*ArticleTag, error) { 62 | entity, err := s.repository.FindOneByID(context.Background(), input.ID) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | if entity == nil { 68 | return nil, NotFoundError 69 | } 70 | 71 | newSortRank, _ := lexorank.Rank(input.SortRank.Prev, input.SortRank.Next) 72 | 73 | entity.ModifiedAt = time.Now() 74 | entity.SortRank = newSortRank 75 | entity.Version = input.Version 76 | 77 | err = s.repository.Update(context.Background(), *entity) 78 | 79 | if errors.Is(err, ArticleIDSortRankConstraintError) { 80 | return nil, InvalidSortRankError 81 | } 82 | 83 | if errors.Is(err, NoRowsAffectedError) { 84 | return nil, VersionMismatchError 85 | } 86 | 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | return entity, nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/http/handler/graphql.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | //goland:noinspection SpellCheckingInspection 4 | import ( 5 | "context" 6 | "fmt" 7 | gqlhandler "github.com/99designs/gqlgen/graphql/handler" 8 | "github.com/99designs/gqlgen/graphql/handler/extension" 9 | "github.com/99designs/gqlgen/graphql/handler/lru" 10 | "github.com/99designs/gqlgen/graphql/handler/transport" 11 | "github.com/acelot/articles/internal/app" 12 | "github.com/acelot/articles/internal/gql/directive" 13 | "github.com/acelot/articles/internal/gql/resolver" 14 | "github.com/acelot/articles/internal/gql/runtime" 15 | "github.com/vektah/gqlparser/v2/gqlerror" 16 | "go.uber.org/zap" 17 | "time" 18 | ) 19 | 20 | const websocketKeepAlivePingInterval = 5 * time.Second 21 | const maxUploadSize = 30 * 1024 * 1024 22 | const queryCacheLRUSize = 1000 23 | const automaticPersistedQueryCacheLRUSize = 100 24 | const complexityLimit = 1000 25 | 26 | func NewGraphQLHandler(env *app.Env) *gqlhandler.Server { 27 | handler := gqlhandler.New( 28 | runtime.NewExecutableSchema( 29 | newSchemaConfig(env), 30 | ), 31 | ) 32 | 33 | // Transports 34 | handler.AddTransport(transport.Websocket{ 35 | KeepAlivePingInterval: websocketKeepAlivePingInterval, 36 | }) 37 | handler.AddTransport(transport.Options{}) 38 | handler.AddTransport(transport.POST{}) 39 | handler.AddTransport(transport.MultipartForm{ 40 | MaxUploadSize: maxUploadSize, 41 | MaxMemory: maxUploadSize / 10, 42 | }) 43 | 44 | // Query cache 45 | handler.SetQueryCache(lru.New(queryCacheLRUSize)) 46 | 47 | // Enabling introspection 48 | handler.Use(extension.Introspection{}) 49 | 50 | // APQ 51 | handler.Use(extension.AutomaticPersistedQuery{Cache: lru.New(automaticPersistedQueryCacheLRUSize)}) 52 | 53 | // Complexity 54 | handler.Use(extension.FixedComplexityLimit(complexityLimit)) 55 | 56 | // Unhandled errors logger 57 | handler.SetRecoverFunc(func(ctx context.Context, err interface{}) (userMessage error) { 58 | env.Logger.Error("unhandled error", zap.String("error", fmt.Sprintf("%v", err))) 59 | 60 | return gqlerror.Errorf("internal server error") 61 | }) 62 | 63 | return handler 64 | } 65 | 66 | func newSchemaConfig(env *app.Env) runtime.Config { 67 | cfg := runtime.Config{Resolvers: resolver.NewResolver(env)} 68 | 69 | cfg.Directives.InputUnion = directive.NewInputUnionDirective() 70 | cfg.Directives.SortRankInput = directive.NewSortRankInputDirective() 71 | 72 | cfg.Complexity.ArticleQuery.Find = resolver.ArticleQueryFindComplexity 73 | cfg.Complexity.ArticleFindList.TotalCount = resolver.ArticleFindListTotalCountComplexity 74 | 75 | return cfg 76 | } 77 | -------------------------------------------------------------------------------- /internal/feature/image/repository.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/acelot/articles/internal/go-pg/condition" 7 | "github.com/go-pg/pg/v10" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | var NoRowsAffectedError = errors.New("no rows affected error") 12 | 13 | type Repository struct { 14 | primaryDB *pg.DB 15 | secondaryDB *pg.DB 16 | } 17 | 18 | type FindFilterIDAnyOf []uuid.UUID 19 | type FindOrderByCreatedAt bool 20 | 21 | func (uuids FindFilterIDAnyOf) Apply(query *pg.Query) { 22 | query.Where(`id = ANY(?)`, pg.Array(uuids)) 23 | } 24 | 25 | func (isDesc FindOrderByCreatedAt) Apply(query *pg.Query) { 26 | dir := "ASC" 27 | if isDesc { 28 | dir = "DESC" 29 | } 30 | 31 | query.Order("created_at " + dir) 32 | } 33 | 34 | func NewRepository(primaryDB *pg.DB, secondaryDB *pg.DB) *Repository { 35 | return &Repository{ 36 | primaryDB, 37 | secondaryDB, 38 | } 39 | } 40 | 41 | func (r *Repository) Find(ctx context.Context, conditions ...condition.Condition) ([]Image, error) { 42 | var items []Image 43 | 44 | query := r.secondaryDB.ModelContext(ctx, &items) 45 | 46 | condition.Apply(query, conditions...) 47 | 48 | err := query.Select() 49 | 50 | return items, err 51 | } 52 | 53 | func (r *Repository) FindOneByID(ctx context.Context, id uuid.UUID) (*Image, error) { 54 | entities, err := r.Find(ctx, FindFilterIDAnyOf{id}) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | if len(entities) == 0 { 60 | return nil, nil 61 | } 62 | 63 | return &entities[0], nil 64 | } 65 | 66 | func (r *Repository) Count(ctx context.Context, estimateThreshold int, conditions ...condition.Condition) (int, error) { 67 | query := r.secondaryDB.ModelContext(ctx, (*Image)(nil)) 68 | 69 | condition.Apply(query, conditions...) 70 | 71 | if estimateThreshold > 0 { 72 | return query.CountEstimate(estimateThreshold) 73 | } 74 | 75 | return query.Count() 76 | } 77 | 78 | 79 | func (r *Repository) Insert(ctx context.Context, entity Image) error { 80 | _, err := r.primaryDB.ModelContext(ctx, &entity).Insert() 81 | 82 | return err 83 | } 84 | 85 | func (r *Repository) Update(ctx context.Context, entity Image) error { 86 | currentVersion := entity.Version 87 | entity.Version++ 88 | 89 | res, err := r.primaryDB.ModelContext(ctx, &entity). 90 | WherePK(). 91 | Where("version = ?", currentVersion). 92 | Update() 93 | 94 | if err != nil { 95 | return err 96 | } 97 | 98 | if res.RowsAffected() == 0 { 99 | return NoRowsAffectedError 100 | } 101 | 102 | return nil 103 | } -------------------------------------------------------------------------------- /internal/feature/tag/repository.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/acelot/articles/internal/go-pg/condition" 7 | "github.com/go-pg/pg/v10" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | const tagNameConstraint string = "tag_name_key" 12 | 13 | var TagNameConstraintError = errors.New("tag name constraint error") 14 | var NoRowsAffectedError = errors.New("no rows affected error") 15 | 16 | type Repository struct { 17 | primaryDB *pg.DB 18 | secondaryDB *pg.DB 19 | } 20 | 21 | type FindFilterIDAnyOf []uuid.UUID 22 | type FindOrderByName bool 23 | type FindOrderByCreatedAt bool 24 | 25 | func (uuids FindFilterIDAnyOf) Apply(query *pg.Query) { 26 | query.Where(`id = ANY(?)`, pg.Array(uuids)) 27 | } 28 | 29 | func (isDesc FindOrderByName) Apply(query *pg.Query) { 30 | dir := "ASC" 31 | if isDesc { 32 | dir = "DESC" 33 | } 34 | 35 | query.Order("name " + dir) 36 | } 37 | 38 | func (isDesc FindOrderByCreatedAt) Apply(query *pg.Query) { 39 | dir := "ASC" 40 | if isDesc { 41 | dir = "DESC" 42 | } 43 | 44 | query.Order("created_at " + dir) 45 | } 46 | 47 | func NewRepository(primaryDB *pg.DB, secondaryDB *pg.DB) *Repository { 48 | return &Repository{ 49 | primaryDB, 50 | secondaryDB, 51 | } 52 | } 53 | 54 | func (r *Repository) Find(ctx context.Context, conditions ...condition.Condition) ([]Tag, error) { 55 | var items []Tag 56 | 57 | query := r.secondaryDB.ModelContext(ctx, &items) 58 | 59 | condition.Apply(query, conditions...) 60 | 61 | err := query.Select() 62 | 63 | return items, err 64 | } 65 | 66 | func (r *Repository) FindOneByID(ctx context.Context, id uuid.UUID) (*Tag, error) { 67 | entities, err := r.Find(ctx, FindFilterIDAnyOf{id}) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | if len(entities) == 0 { 73 | return nil, nil 74 | } 75 | 76 | return &entities[0], nil 77 | } 78 | 79 | func (r *Repository) Count(ctx context.Context, estimateThreshold int, conditions ...condition.Condition) (int, error) { 80 | query := r.secondaryDB.ModelContext(ctx, (*Tag)(nil)) 81 | 82 | condition.Apply(query, conditions...) 83 | 84 | if estimateThreshold > 0 { 85 | return query.CountEstimate(estimateThreshold) 86 | } 87 | 88 | return query.Count() 89 | } 90 | 91 | func (r *Repository) Insert(ctx context.Context, entity Tag) error { 92 | _, err := r.primaryDB.ModelContext(ctx, &entity).Insert() 93 | 94 | return specifyError(err) 95 | } 96 | 97 | func (r *Repository) Update(ctx context.Context, entity Tag) error { 98 | currentVersion := entity.Version 99 | entity.Version++ 100 | 101 | res, err := r.primaryDB.ModelContext(ctx, &entity). 102 | WherePK(). 103 | Where("version = ?", currentVersion). 104 | Update() 105 | 106 | if err != nil { 107 | return specifyError(err) 108 | } 109 | 110 | if res.RowsAffected() == 0 { 111 | return NoRowsAffectedError 112 | } 113 | 114 | return nil 115 | } 116 | 117 | func specifyError(err error) error { 118 | pgErr, ok := err.(pg.Error) 119 | if !ok { 120 | return err 121 | } 122 | 123 | constraint := pgErr.Field([]byte("n")[0]) 124 | 125 | if constraint == tagNameConstraint { 126 | return TagNameConstraintError 127 | } 128 | 129 | return err 130 | } 131 | -------------------------------------------------------------------------------- /internal/feature/article/repository.go: -------------------------------------------------------------------------------- 1 | package article 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/acelot/articles/internal/go-pg/condition" 7 | "github.com/go-pg/pg/v10" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | const projectIDConstraint = "article_project_id_fkey" 12 | 13 | var NoRowsAffectedError = errors.New("no rows affected error") 14 | var ProjectIDConstraintError = errors.New("project ID constraint error") 15 | 16 | type Repository struct { 17 | primaryDB *pg.DB 18 | secondaryDB *pg.DB 19 | } 20 | 21 | type FindFilterIDAnyOf []uuid.UUID 22 | type FindOrderByCreatedAt bool 23 | type FindOrderByModifiedAt bool 24 | 25 | func (uuids FindFilterIDAnyOf) Apply(query *pg.Query) { 26 | query.Where(`id = ANY(?)`, pg.Array(uuids)) 27 | } 28 | 29 | func (isDesc FindOrderByCreatedAt) Apply(query *pg.Query) { 30 | dir := "ASC" 31 | if isDesc { 32 | dir = "DESC" 33 | } 34 | 35 | query.Order("created_at " + dir) 36 | } 37 | 38 | func (isDesc FindOrderByModifiedAt) Apply(query *pg.Query) { 39 | dir := "ASC" 40 | if isDesc { 41 | dir = "DESC" 42 | } 43 | 44 | query.Order("modified_at " + dir) 45 | } 46 | 47 | func NewRepository(primaryDB *pg.DB, secondaryDB *pg.DB) *Repository { 48 | return &Repository{ 49 | primaryDB, 50 | secondaryDB, 51 | } 52 | } 53 | 54 | func (r *Repository) Find(ctx context.Context, conditions ...condition.Condition) ([]Article, error) { 55 | var items []Article 56 | 57 | query := r.secondaryDB.ModelContext(ctx, &items) 58 | 59 | condition.Apply(query, conditions...) 60 | 61 | err := query.Select() 62 | 63 | return items, err 64 | } 65 | 66 | func (r *Repository) FindOneByID(ctx context.Context, id uuid.UUID) (*Article, error) { 67 | entities, err := r.Find( 68 | ctx, 69 | FindFilterIDAnyOf{id}, 70 | condition.Limit(1), 71 | ) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | if len(entities) == 0 { 77 | return nil, nil 78 | } 79 | 80 | return &entities[0], nil 81 | } 82 | 83 | func (r *Repository) Count(ctx context.Context, estimateThreshold int, conditions ...condition.Condition) (int, error) { 84 | query := r.secondaryDB.ModelContext(ctx, (*Article)(nil)) 85 | 86 | condition.Apply(query, conditions...) 87 | 88 | if estimateThreshold > 0 { 89 | return query.CountEstimate(estimateThreshold) 90 | } 91 | 92 | return query.Count() 93 | } 94 | 95 | func (r *Repository) Insert(ctx context.Context, entity Article) error { 96 | _, err := r.primaryDB.ModelContext(ctx, &entity).Insert() 97 | 98 | return specifyError(err) 99 | } 100 | 101 | func (r *Repository) Update(ctx context.Context, entity *Article) error { 102 | currentVersion := entity.Version 103 | entity.Version++ 104 | 105 | res, err := r.primaryDB.ModelContext(ctx, entity). 106 | WherePK(). 107 | Where("version = ?", currentVersion). 108 | Update() 109 | 110 | if err != nil { 111 | return specifyError(err) 112 | } 113 | 114 | if res.RowsAffected() == 0 { 115 | return NoRowsAffectedError 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func specifyError(err error) error { 122 | pgErr, ok := err.(pg.Error) 123 | if !ok { 124 | return err 125 | } 126 | 127 | constraint := pgErr.Field([]byte("n")[0]) 128 | 129 | if constraint == projectIDConstraint { 130 | return ProjectIDConstraintError 131 | } 132 | 133 | return err 134 | } -------------------------------------------------------------------------------- /internal/feature/project/repository.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/acelot/articles/internal/go-pg/condition" 7 | "github.com/go-pg/pg/v10" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | const projectNameConstraint string = "project_name_key" 12 | 13 | var ProjectNameConstraintError = errors.New("project name constraint error") 14 | var NoRowsAffectedError = errors.New("no rows affected error") 15 | 16 | type Repository struct { 17 | primaryDB *pg.DB 18 | secondaryDB *pg.DB 19 | } 20 | 21 | type FindFilterIDAnyOf []uuid.UUID 22 | type FindOrderByName bool 23 | type FindOrderByCreatedAt bool 24 | type FindOrderByModifiedAt bool 25 | 26 | func (uuids FindFilterIDAnyOf) Apply(query *pg.Query) { 27 | query.Where(`id = ANY(?)`, pg.Array(uuids)) 28 | } 29 | 30 | func (isDesc FindOrderByName) Apply(query *pg.Query) { 31 | dir := "ASC" 32 | if isDesc { 33 | dir = "DESC" 34 | } 35 | 36 | query.Order("name " + dir) 37 | } 38 | 39 | func (isDesc FindOrderByCreatedAt) Apply(query *pg.Query) { 40 | dir := "ASC" 41 | if isDesc { 42 | dir = "DESC" 43 | } 44 | 45 | query.Order("created_at " + dir) 46 | } 47 | 48 | func (isDesc FindOrderByModifiedAt) Apply(query *pg.Query) { 49 | dir := "ASC" 50 | if isDesc { 51 | dir = "DESC" 52 | } 53 | 54 | query.Order("modified_at " + dir) 55 | } 56 | 57 | func NewRepository(primaryDB *pg.DB, secondaryDB *pg.DB) *Repository { 58 | return &Repository{ 59 | primaryDB, 60 | secondaryDB, 61 | } 62 | } 63 | 64 | func (r *Repository) Find(ctx context.Context, conditions ...condition.Condition) ([]Project, error) { 65 | var items []Project 66 | 67 | query := r.secondaryDB.ModelContext(ctx, &items) 68 | 69 | condition.Apply(query, conditions...) 70 | 71 | err := query.Select() 72 | 73 | return items, err 74 | } 75 | 76 | func (r *Repository) FindOneByID(ctx context.Context, id uuid.UUID) (*Project, error) { 77 | entities, err := r.Find(ctx, FindFilterIDAnyOf{id}) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | if len(entities) == 0 { 83 | return nil, nil 84 | } 85 | 86 | return &entities[0], nil 87 | } 88 | 89 | func (r *Repository) Count(ctx context.Context, conditions ...condition.Condition) (int, error) { 90 | query := r.secondaryDB.ModelContext(ctx, (*Project)(nil)) 91 | 92 | condition.Apply(query, conditions...) 93 | 94 | return query.Count() 95 | } 96 | 97 | func (r *Repository) Insert(ctx context.Context, entity Project) error { 98 | _, err := r.primaryDB.ModelContext(ctx, &entity).Insert() 99 | 100 | return specifyError(err) 101 | } 102 | 103 | func (r *Repository) Update(ctx context.Context, entity Project) error { 104 | currentVersion := entity.Version 105 | entity.Version++ 106 | 107 | res, err := r.primaryDB.ModelContext(ctx, &entity). 108 | WherePK(). 109 | Where("version = ?", currentVersion). 110 | Update() 111 | 112 | if err != nil { 113 | return specifyError(err) 114 | } 115 | 116 | if res.RowsAffected() == 0 { 117 | return NoRowsAffectedError 118 | } 119 | 120 | return nil 121 | } 122 | 123 | func specifyError(err error) error { 124 | pgErr, ok := err.(pg.Error) 125 | if !ok { 126 | return err 127 | } 128 | 129 | constraint := pgErr.Field([]byte("n")[0]) 130 | 131 | if constraint == projectNameConstraint { 132 | return ProjectNameConstraintError 133 | } 134 | 135 | return err 136 | } -------------------------------------------------------------------------------- /internal/app/args.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "github.com/docopt/docopt-go" 6 | "github.com/go-pg/pg/v10" 7 | "go.uber.org/zap/zapcore" 8 | "net/url" 9 | "regexp" 10 | "time" 11 | ) 12 | 13 | type Args struct { 14 | ListenAddress string 15 | PrimaryDatabaseDSN string 16 | SecondaryDatabaseDSN string 17 | ImageStorageURI string 18 | LogLevel string 19 | ShutdownTimeout time.Duration 20 | } 21 | 22 | func NewArgs(opts *docopt.Opts) (*Args, error) { 23 | listenAddress, _ := opts.String("--listen") 24 | if checkListenAddress(listenAddress) == false { 25 | return nil, fmt.Errorf("invalid option --listen; format: 127.0.0.1:80") 26 | } 27 | 28 | primaryDatabaseDSN, _ := opts.String("") 29 | if checkPostgresDSN(primaryDatabaseDSN) == false { 30 | return nil, fmt.Errorf("invalid argument ; format: postgres://user:pass@host:port/db?option=value") 31 | } 32 | 33 | secondaryDatabaseDSN, _ := opts.String("--secondary-db") 34 | if secondaryDatabaseDSN != "" && checkPostgresDSN(secondaryDatabaseDSN) == false { 35 | return nil, fmt.Errorf("invalid option --secondary-db; format: postgres://user:pass@host:port/db?option=value") 36 | } 37 | 38 | imageStorageURI, _ := opts.String("--image-storage") 39 | if checkImageStorageURI(imageStorageURI) == false { 40 | return nil, fmt.Errorf("invalid option --image-storage; format: http(s)://id:secret@host:port/bucket") 41 | } 42 | 43 | logLevel, _ := opts.String("--log-level") 44 | if checkLogLevel(logLevel) == false { 45 | return nil, fmt.Errorf("invalid option --log-level; allowed values: %v", getAllowedLogLevels()) 46 | } 47 | 48 | shutdownTimeout, _ := opts.Int("--shutdown-timeout") 49 | if shutdownTimeout < 0 { 50 | return nil, fmt.Errorf("invalid option --shutdown-timeout; must be greater or equal zero") 51 | } 52 | 53 | return &Args{ 54 | ListenAddress: listenAddress, 55 | PrimaryDatabaseDSN: primaryDatabaseDSN, 56 | SecondaryDatabaseDSN: secondaryDatabaseDSN, 57 | ImageStorageURI: imageStorageURI, 58 | LogLevel: logLevel, 59 | ShutdownTimeout: time.Duration(shutdownTimeout) * time.Second, 60 | }, nil 61 | } 62 | 63 | func checkListenAddress(addr string) bool { 64 | pattern := regexp.MustCompile(`^(?P\d+\.\d+\.\d+\.\d+):(?P\d+)$`) 65 | 66 | return pattern.MatchString(addr) 67 | } 68 | 69 | func checkPostgresDSN(dsn string) bool { 70 | _, err := pg.ParseURL(dsn) 71 | 72 | return err == nil 73 | } 74 | 75 | func checkImageStorageURI(uri string) bool { 76 | parsed, err := url.Parse(uri) 77 | if err != nil { 78 | return false 79 | } 80 | 81 | if parsed.Scheme != "http" && parsed.Scheme != "https" { 82 | return false 83 | } 84 | 85 | if parsed.User.Username() == "" { 86 | return false 87 | } 88 | 89 | _, isPasswordSet := parsed.User.Password() 90 | if !isPasswordSet { 91 | return false 92 | } 93 | 94 | if parsed.Path == "" { 95 | return false 96 | } 97 | 98 | return true 99 | } 100 | 101 | func checkLogLevel(level string) bool { 102 | for _, l := range getAllowedLogLevels() { 103 | if level == l { 104 | return true 105 | } 106 | } 107 | 108 | return false 109 | } 110 | 111 | func getAllowedLogLevels() []string { 112 | return []string{ 113 | zapcore.DebugLevel.String(), 114 | zapcore.InfoLevel.String(), 115 | zapcore.WarnLevel.String(), 116 | zapcore.ErrorLevel.String(), 117 | } 118 | } -------------------------------------------------------------------------------- /internal/feature/tag/service.go: -------------------------------------------------------------------------------- 1 | package tag 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/acelot/articles/internal/go-pg/condition" 8 | "github.com/acelot/articles/internal/gql/model" 9 | "github.com/google/uuid" 10 | "time" 11 | ) 12 | 13 | var NotFoundError = errors.New("tag not found error") 14 | var VersionMismatchError = errors.New("version mismatch error") 15 | 16 | type Service struct { 17 | repository *Repository 18 | } 19 | 20 | func NewService(tagRepository *Repository) *Service { 21 | return &Service{tagRepository} 22 | } 23 | 24 | func (s *Service) FindTags( 25 | filter *model.TagFindFilterInput, 26 | sort model.TagFindSortEnum, 27 | pageSize uint, 28 | pageNumber uint, 29 | ) ([]Tag, error) { 30 | orderBy, err := mapFindSortEnumToRepositoryCondition(sort) 31 | if err != nil { 32 | return []Tag{}, err 33 | } 34 | 35 | conditions := mapFindFilterInputToRepositoryConditions(filter) 36 | 37 | conditions = append( 38 | conditions, 39 | condition.Limit(pageSize), 40 | condition.Offset((pageNumber-1)*pageSize), 41 | orderBy, 42 | ) 43 | 44 | return s.repository.Find(context.Background(), conditions...) 45 | } 46 | 47 | func (s *Service) CountTags(filter *model.TagFindFilterInput, estimate uint) (uint, error) { 48 | conditions := mapFindFilterInputToRepositoryConditions(filter) 49 | 50 | c, err := s.repository.Count(context.Background(), int(estimate), conditions...) 51 | 52 | return uint(c), err 53 | } 54 | 55 | func (s *Service) CreateTag(input model.TagCreateInput) (*Tag, error) { 56 | entity := Tag{ 57 | CreatedAt: time.Now(), 58 | ID: uuid.New(), 59 | ModifiedAt: time.Now(), 60 | Name: input.Name, 61 | Version: 0, 62 | } 63 | 64 | if err := s.repository.Insert(context.Background(), entity); err != nil { 65 | return nil, err 66 | } 67 | 68 | return &entity, nil 69 | } 70 | 71 | func (s *Service) UpdateTag(input model.TagUpdateInput) (*Tag, error) { 72 | entity, err := s.repository.FindOneByID(context.Background(), input.ID) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | if entity == nil { 78 | return nil, NotFoundError 79 | } 80 | 81 | entity.ModifiedAt = time.Now() 82 | entity.Name = input.Name 83 | entity.Version = input.Version 84 | 85 | err = s.repository.Update(context.Background(), *entity) 86 | 87 | if errors.Is(err, NoRowsAffectedError) { 88 | return nil, VersionMismatchError 89 | } 90 | 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | return entity, nil 96 | } 97 | 98 | func mapFindFilterInputToRepositoryConditions(filter *model.TagFindFilterInput) (conditions []condition.Condition) { 99 | if filter == nil { 100 | return 101 | } 102 | 103 | if filter.IDAnyOf != nil { 104 | conditions = append(conditions, FindFilterIDAnyOf(filter.IDAnyOf)) 105 | } 106 | 107 | return 108 | } 109 | 110 | func mapFindSortEnumToRepositoryCondition(sort model.TagFindSortEnum) (condition.Condition, error) { 111 | switch sort { 112 | case model.TagFindSortEnumNameAsc: 113 | return FindOrderByName(false), nil 114 | case model.TagFindSortEnumNameDesc: 115 | return FindOrderByName(true), nil 116 | case model.TagFindSortEnumCreatedAtAsc: 117 | return FindOrderByCreatedAt(false), nil 118 | case model.TagFindSortEnumCreatedAtDesc: 119 | return FindOrderByCreatedAt(true), nil 120 | default: 121 | return condition.None{}, fmt.Errorf(`not mapped sort value "%s"`, sort) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /internal/feature/article/service.go: -------------------------------------------------------------------------------- 1 | package article 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/acelot/articles/internal/go-pg/condition" 8 | "github.com/acelot/articles/internal/gql/model" 9 | "github.com/google/uuid" 10 | "time" 11 | ) 12 | 13 | var NotFoundError = errors.New("article not found error") 14 | var VersionMismatchError = errors.New("version mismatch error") 15 | 16 | type Service struct { 17 | repository *Repository 18 | } 19 | 20 | func NewService(repository *Repository) *Service { 21 | return &Service{repository} 22 | } 23 | 24 | func (s *Service) FindArticles( 25 | filter *model.ArticleFindFilterInput, 26 | sort model.ArticleFindSortEnum, 27 | pageSize uint, 28 | pageNumber uint, 29 | ) ([]Article, error) { 30 | orderBy, err := mapFindSortEnumToRepositoryCondition(sort) 31 | if err != nil { 32 | return []Article{}, err 33 | } 34 | 35 | conditions := mapFindFilterInputToRepositoryConditions(filter) 36 | 37 | conditions = append( 38 | conditions, 39 | condition.Limit(pageSize), 40 | condition.Offset((pageNumber-1)*pageSize), 41 | orderBy, 42 | ) 43 | 44 | return s.repository.Find(context.Background(), conditions...) 45 | } 46 | 47 | func (s *Service) CountArticles(filter *model.ArticleFindFilterInput, estimate uint) (uint, error) { 48 | conditions := mapFindFilterInputToRepositoryConditions(filter) 49 | 50 | c, err := s.repository.Count(context.Background(), int(estimate), conditions...) 51 | 52 | return uint(c), err 53 | } 54 | 55 | func (s *Service) CreateArticle(projectID uuid.UUID) (*Article, error) { 56 | createdAt := time.Now() 57 | 58 | entity := Article{ 59 | CoverImageID: nil, 60 | CreatedAt: createdAt, 61 | ID: uuid.New(), 62 | ModifiedAt: createdAt, 63 | ProjectID: projectID, 64 | Title: fmt.Sprintf("New article %s", createdAt.Format("2006-01-02T15:04:05-0700")), 65 | Version: 0, 66 | } 67 | 68 | if err := s.repository.Insert(context.Background(), entity); err != nil { 69 | return nil, err 70 | } 71 | 72 | return &entity, nil 73 | } 74 | 75 | func (s *Service) UpdateArticle(input model.ArticleUpdateInput) (*Article, error) { 76 | entity, err := s.repository.FindOneByID(context.Background(), input.ID) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | if entity == nil { 82 | return nil, NotFoundError 83 | } 84 | 85 | entity.CoverImageID = input.CoverImageID 86 | entity.ModifiedAt = time.Now() 87 | entity.Title = input.Title 88 | entity.Version = input.Version 89 | 90 | err = s.repository.Update(context.Background(), entity) 91 | 92 | if errors.Is(err, NoRowsAffectedError) { 93 | return nil, VersionMismatchError 94 | } 95 | 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | return entity, nil 101 | } 102 | 103 | func mapFindFilterInputToRepositoryConditions(filter *model.ArticleFindFilterInput) (conditions []condition.Condition) { 104 | if filter != nil && filter.IDAnyOf != nil { 105 | conditions = append(conditions, FindFilterIDAnyOf(filter.IDAnyOf)) 106 | } 107 | 108 | return 109 | } 110 | 111 | func mapFindSortEnumToRepositoryCondition(sort model.ArticleFindSortEnum) (condition.Condition, error) { 112 | switch sort { 113 | case model.ArticleFindSortEnumCreatedAtAsc: 114 | return FindOrderByCreatedAt(false), nil 115 | case model.ArticleFindSortEnumCreatedAtDesc: 116 | return FindOrderByCreatedAt(true), nil 117 | default: 118 | return nil, fmt.Errorf(`not mapped sort value "%s"`, sort) 119 | } 120 | } -------------------------------------------------------------------------------- /internal/feature/project/service.go: -------------------------------------------------------------------------------- 1 | package project 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/acelot/articles/internal/go-pg/condition" 8 | "github.com/acelot/articles/internal/gql/model" 9 | "github.com/google/uuid" 10 | "time" 11 | ) 12 | 13 | var NotFoundError = errors.New("project not found error") 14 | var VersionMismatchError = errors.New("version mismatch error") 15 | 16 | type Service struct { 17 | repository *Repository 18 | } 19 | 20 | func NewService(repository *Repository) *Service { 21 | return &Service{repository} 22 | } 23 | 24 | func (s *Service) FindProjects( 25 | filter *model.ProjectFindFilterInput, 26 | sort model.ProjectFindSortEnum, 27 | pageSize uint, 28 | pageNumber uint, 29 | ) ([]Project, error) { 30 | orderBy, err := mapFindSortEnumToRepositoryCondition(sort) 31 | if err != nil { 32 | return []Project{}, err 33 | } 34 | 35 | conditions := mapFindFilterInputToRepositoryConditions(filter) 36 | 37 | conditions = append( 38 | conditions, 39 | condition.Limit(pageSize), 40 | condition.Offset((pageNumber-1)*pageSize), 41 | orderBy, 42 | ) 43 | 44 | return s.repository.Find(context.Background(), conditions...) 45 | } 46 | 47 | func (s *Service) CountProjects(filter *model.ProjectFindFilterInput) (uint, error) { 48 | conditions := mapFindFilterInputToRepositoryConditions(filter) 49 | 50 | c, err := s.repository.Count(context.Background(), conditions...) 51 | 52 | return uint(c), err 53 | } 54 | 55 | func (s *Service) CreateProject(input model.ProjectCreateInput) (*Project, error) { 56 | entity := Project{ 57 | CreatedAt: time.Now(), 58 | ID: uuid.New(), 59 | ModifiedAt: time.Now(), 60 | Name: input.Name, 61 | Version: 0, 62 | } 63 | 64 | if err := s.repository.Insert(context.Background(), entity); err != nil { 65 | return nil, err 66 | } 67 | 68 | return &entity, nil 69 | } 70 | 71 | func (s *Service) UpdateProject(input model.ProjectUpdateInput) (*Project, error) { 72 | entity, err := s.repository.FindOneByID(context.Background(), input.ID) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | if entity == nil { 78 | return nil, NotFoundError 79 | } 80 | 81 | entity.ModifiedAt = time.Now() 82 | entity.Name = input.Name 83 | entity.Version = input.Version 84 | 85 | err = s.repository.Update(context.Background(), *entity) 86 | 87 | if errors.Is(err, NoRowsAffectedError) { 88 | return nil, VersionMismatchError 89 | } 90 | 91 | if err != nil { 92 | return nil, err 93 | } 94 | 95 | return entity, nil 96 | } 97 | 98 | func mapFindFilterInputToRepositoryConditions(filter *model.ProjectFindFilterInput) (conditions []condition.Condition) { 99 | if filter == nil { 100 | return 101 | } 102 | 103 | if filter.IDAnyOf != nil { 104 | conditions = append(conditions, FindFilterIDAnyOf(filter.IDAnyOf)) 105 | } 106 | 107 | return 108 | } 109 | 110 | func mapFindSortEnumToRepositoryCondition(sort model.ProjectFindSortEnum) (condition.Condition, error) { 111 | switch sort { 112 | case model.ProjectFindSortEnumNameAsc: 113 | return FindOrderByName(false), nil 114 | case model.ProjectFindSortEnumNameDesc: 115 | return FindOrderByName(true), nil 116 | case model.ProjectFindSortEnumCreatedAtAsc: 117 | return FindOrderByCreatedAt(false), nil 118 | case model.ProjectFindSortEnumCreatedAtDesc: 119 | return FindOrderByCreatedAt(false), nil 120 | default: 121 | return condition.None{}, fmt.Errorf(`not mapped sort value "%s"`, sort) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /internal/gql/resolver/article.go: -------------------------------------------------------------------------------- 1 | package resolver 2 | 3 | // This file will be automatically regenerated based on the schema, any resolver implementations 4 | // will be copied through when generating and any unknown code will be moved to the end. 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/acelot/articles/internal/app" 10 | "github.com/acelot/articles/internal/feature/articleblock" 11 | "github.com/acelot/articles/internal/feature/articletag" 12 | "github.com/acelot/articles/internal/feature/image" 13 | "github.com/acelot/articles/internal/feature/project" 14 | "github.com/acelot/articles/internal/gql/model" 15 | "github.com/acelot/articles/internal/gql/runtime" 16 | "go.uber.org/zap" 17 | ) 18 | 19 | func (r *articleResolver) Content(ctx context.Context, obj *model.Article) (model.ArticleContentResolvingResult, error) { 20 | dataLoader := ctx.Value(app.DataLoadersContextKey).(*app.DataLoaders).ArticleBlockLoaderByArticleID 21 | 22 | articleBlocks, err := dataLoader.Load(articleblock.LoaderByArticleIDKey{ArticleID: obj.ID}) 23 | if err != nil { 24 | r.env.Logger.Error("articleblock.LoaderByArticleID.Load", zap.Error(err)) 25 | 26 | return NewInternalErrorProblem(), nil 27 | } 28 | 29 | gqlArticleBlocks, err := articleblock.MapManyToGqlModels(articleBlocks) 30 | if err != nil { 31 | r.env.Logger.Error("articleblock.MapManyToGqlModels", zap.Error(err)) 32 | 33 | return NewInternalErrorProblem(), nil 34 | } 35 | 36 | return model.ArticleContent{ 37 | Blocks: gqlArticleBlocks, 38 | }, nil 39 | } 40 | 41 | func (r *articleResolver) CoverImage(ctx context.Context, obj *model.Article) (model.ImageResolvingResult, error) { 42 | dataLoader := ctx.Value(app.DataLoadersContextKey).(*app.DataLoaders).ImageLoaderByID 43 | 44 | if obj.CoverImageID == nil { 45 | return model.ImageNotFoundProblem{ 46 | Message: "no cover image", 47 | }, nil 48 | } 49 | 50 | img, err := dataLoader.Load(image.LoaderByIDKey{ID: *obj.CoverImageID}) 51 | if err != nil { 52 | r.env.Logger.Error("image.LoaderByID.Load", zap.Error(err)) 53 | 54 | return NewInternalErrorProblem(), nil 55 | } 56 | 57 | if img == nil { 58 | return model.ImageNotFoundProblem{ 59 | Message: "cover image not found", 60 | }, nil 61 | } 62 | 63 | gqlImage, err := image.MapOneToGqlModel(*img) 64 | if err != nil { 65 | r.env.Logger.Error("image.MapOneToGqlModel", zap.Error(err)) 66 | 67 | return NewInternalErrorProblem(), nil 68 | } 69 | 70 | return *gqlImage, nil 71 | } 72 | 73 | func (r *articleResolver) Project(ctx context.Context, obj *model.Article) (model.ProjectResolvingResult, error) { 74 | dataLoader := ctx.Value(app.DataLoadersContextKey).(*app.DataLoaders).ProjectLoaderByID 75 | 76 | proj, err := dataLoader.Load(project.LoaderByIDKey{ID: obj.ProjectID}) 77 | if err != nil { 78 | r.env.Logger.Error("project.LoaderByID.Load", zap.Error(err)) 79 | 80 | return NewInternalErrorProblem(), nil 81 | } 82 | 83 | if proj == nil { 84 | return model.ProjectNotFoundProblem{ 85 | Message: "project not found", 86 | }, nil 87 | } 88 | 89 | return project.MapOneToGqlModel(*proj), nil 90 | } 91 | 92 | func (r *articleResolver) Tags(ctx context.Context, obj *model.Article) (model.ArticleTagsResolvingResult, error) { 93 | dataLoader := ctx.Value(app.DataLoadersContextKey).(*app.DataLoaders).ArticleTagLoaderByArticleID 94 | 95 | articleTags, err := dataLoader.Load(articletag.LoaderByArticleIDKey{ArticleID: obj.ID}) 96 | if err != nil { 97 | r.env.Logger.Error("articletag.LoaderByArticleID.Load", zap.Error(err)) 98 | 99 | return NewInternalErrorProblem(), nil 100 | } 101 | 102 | return model.ArticleTagList{ 103 | Items: articletag.MapManyToGqlModels(articleTags), 104 | }, nil 105 | } 106 | 107 | // Article returns runtime.ArticleResolver implementation. 108 | func (r *Resolver) Article() runtime.ArticleResolver { return &articleResolver{r} } 109 | 110 | type articleResolver struct{ *Resolver } 111 | -------------------------------------------------------------------------------- /internal/feature/articleblock/repository.go: -------------------------------------------------------------------------------- 1 | package articleblock 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/acelot/articles/internal/go-pg/condition" 7 | "github.com/go-pg/pg/v10" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | const articleIDConstraint string = "article_block_article_id_fkey" 12 | const articleIDSortRankConstraint string = "article_block_article_id_sort_rank_key" 13 | 14 | var NoRowsAffectedError = errors.New("no rows affected error") 15 | var ArticleIDConstraintError = errors.New("article ID constraint error") 16 | var ArticleIDSortRankConstraintError = errors.New("article ID and sort rank sort rank error") 17 | 18 | type Repository struct { 19 | primaryDB *pg.DB 20 | secondaryDB *pg.DB 21 | } 22 | 23 | type FindFilterArticleIDAnyOf []uuid.UUID 24 | type FindFilterIDAnyOf []uuid.UUID 25 | type FindFilterSortRankFrom string 26 | type FindFilterSortRankTo string 27 | type FindOrderBySortRank bool 28 | type FindOrderByCreatedAt bool 29 | 30 | func (uuids FindFilterArticleIDAnyOf) Apply(query *pg.Query) { 31 | query.Where(`article_id = ANY(?)`, pg.Array(uuids)) 32 | } 33 | 34 | func (uuids FindFilterIDAnyOf) Apply(query *pg.Query) { 35 | query.Where(`id = ANY(?)`, pg.Array(uuids)) 36 | } 37 | 38 | func (rank FindFilterSortRankFrom) Apply(query *pg.Query) { 39 | query.Where(`sort_rank >= ?`, rank) 40 | } 41 | 42 | func (rank FindFilterSortRankTo) Apply(query *pg.Query) { 43 | query.Where(`sort_rank <= ?`, rank) 44 | } 45 | 46 | func (isDesc FindOrderBySortRank) Apply(query *pg.Query) { 47 | dir := "ASC" 48 | if isDesc { 49 | dir = "DESC" 50 | } 51 | 52 | query.Order("sort_rank " + dir) 53 | } 54 | 55 | func (isDesc FindOrderByCreatedAt) Apply(query *pg.Query) { 56 | dir := "ASC" 57 | if isDesc { 58 | dir = "DESC" 59 | } 60 | 61 | query.Order("created_at " + dir) 62 | } 63 | 64 | func NewRepository(primaryDB *pg.DB, secondaryDB *pg.DB) *Repository { 65 | return &Repository{ 66 | primaryDB, 67 | secondaryDB, 68 | } 69 | } 70 | 71 | func (r *Repository) Find(ctx context.Context, conditions ...condition.Condition) ([]ArticleBlock, error) { 72 | var items []ArticleBlock 73 | 74 | query := r.secondaryDB.ModelContext(ctx, &items) 75 | 76 | condition.Apply(query, conditions...) 77 | 78 | err := query.Select() 79 | 80 | return items, err 81 | } 82 | 83 | func (r *Repository) FindOneByID(ctx context.Context, id uuid.UUID) (*ArticleBlock, error) { 84 | entities, err := r.Find( 85 | ctx, 86 | FindFilterIDAnyOf{id}, 87 | condition.Limit(1), 88 | ) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | if len(entities) == 0 { 94 | return nil, nil 95 | } 96 | 97 | return &entities[0], nil 98 | } 99 | 100 | func (r *Repository) Count(ctx context.Context, estimateThreshold int, conditions ...condition.Condition) (int, error) { 101 | query := r.secondaryDB.ModelContext(ctx, (*ArticleBlock)(nil)) 102 | 103 | condition.Apply(query, conditions...) 104 | 105 | if estimateThreshold > 0 { 106 | return query.CountEstimate(estimateThreshold) 107 | } 108 | 109 | return query.Count() 110 | } 111 | 112 | func (r *Repository) Insert(ctx context.Context, entity ArticleBlock) error { 113 | _, err := r.primaryDB.ModelContext(ctx, &entity).Insert() 114 | 115 | return specifyError(err) 116 | } 117 | 118 | func (r *Repository) Update(ctx context.Context, entity ArticleBlock) error { 119 | currentVersion := entity.Version 120 | entity.Version++ 121 | 122 | res, err := r.primaryDB.ModelContext(ctx, &entity). 123 | WherePK(). 124 | Where("version = ?", currentVersion). 125 | Update() 126 | 127 | if err != nil { 128 | return specifyError(err) 129 | } 130 | 131 | if res.RowsAffected() == 0 { 132 | return NoRowsAffectedError 133 | } 134 | 135 | return nil 136 | } 137 | 138 | func specifyError(err error) error { 139 | pgErr, ok := err.(pg.Error) 140 | if !ok { 141 | return err 142 | } 143 | 144 | constraint := pgErr.Field([]byte("n")[0]) 145 | 146 | if constraint == articleIDConstraint { 147 | return ArticleIDConstraintError 148 | } 149 | 150 | if constraint == articleIDSortRankConstraint { 151 | return ArticleIDSortRankConstraintError 152 | } 153 | 154 | return err 155 | } -------------------------------------------------------------------------------- /internal/feature/articletag/repository.go: -------------------------------------------------------------------------------- 1 | package articletag 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "github.com/acelot/articles/internal/go-pg/condition" 7 | "github.com/go-pg/pg/v10" 8 | "github.com/google/uuid" 9 | ) 10 | 11 | const articleIDConstraint string = "article_tag_article_id_fkey" 12 | const articleIDTagIDConstraint string = "article_tag_article_id_tag_id_key" 13 | const articleIDSortRankConstraint string = "article_tag_article_id_sort_rank_key" 14 | const tagIDConstraint string = "article_tag_tag_id_fkey" 15 | 16 | var ArticleIDConstraintError = errors.New("article ID constraint error") 17 | var ArticleIDSortRankConstraintError = errors.New("article ID and sort rank constraint error") 18 | var ArticleIDTagIDConstraintError = errors.New("article ID and tag ID constraint error") 19 | var NoRowsAffectedError = errors.New("no rows affected error") 20 | var TagIDConstraintError = errors.New("tag ID constraint error") 21 | 22 | type Repository struct { 23 | primaryDB *pg.DB 24 | secondaryDB *pg.DB 25 | } 26 | 27 | type FindFilterArticleIDAnyOf []uuid.UUID 28 | type FindFilterIDAnyOf []uuid.UUID 29 | type FindFilterSortRankFrom string 30 | type FindFilterSortRankTo string 31 | type FindOrderBySortRank bool 32 | 33 | func (uuids FindFilterArticleIDAnyOf) Apply(query *pg.Query) { 34 | query.Where(`article_id = ANY(?)`, pg.Array(uuids)) 35 | } 36 | 37 | func (uuids FindFilterIDAnyOf) Apply(query *pg.Query) { 38 | query.Where(`id = ANY(?)`, pg.Array(uuids)) 39 | } 40 | 41 | func (rank FindFilterSortRankFrom) Apply(query *pg.Query) { 42 | query.Where(`sort_rank >= ?`, rank) 43 | } 44 | 45 | func (rank FindFilterSortRankTo) Apply(query *pg.Query) { 46 | query.Where(`sort_rank <= ?`, rank) 47 | } 48 | 49 | func (isDesc FindOrderBySortRank) Apply(query *pg.Query) { 50 | dir := "ASC" 51 | if isDesc { 52 | dir = "DESC" 53 | } 54 | 55 | query.Order("sort_rank " + dir) 56 | } 57 | 58 | func NewRepository(primaryDB *pg.DB, secondaryDB *pg.DB) *Repository { 59 | return &Repository{ 60 | primaryDB, 61 | secondaryDB, 62 | } 63 | } 64 | 65 | func (r *Repository) Find(ctx context.Context, conditions ...condition.Condition) ([]ArticleTag, error) { 66 | var items []ArticleTag 67 | 68 | query := r.secondaryDB.ModelContext(ctx, &items) 69 | 70 | condition.Apply(query, conditions...) 71 | 72 | err := query.Select() 73 | 74 | return items, err 75 | } 76 | 77 | func (r *Repository) FindOneByID(ctx context.Context, id uuid.UUID) (*ArticleTag, error) { 78 | entities, err := r.Find( 79 | ctx, 80 | FindFilterIDAnyOf{id}, 81 | condition.Limit(1), 82 | ) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | if len(entities) == 0 { 88 | return nil, nil 89 | } 90 | 91 | return &entities[0], nil 92 | } 93 | 94 | func (r *Repository) Count(ctx context.Context, conditions ...condition.Condition) (int, error) { 95 | query := r.secondaryDB.ModelContext(ctx, (*ArticleTag)(nil)) 96 | 97 | condition.Apply(query, conditions...) 98 | 99 | return query.Count() 100 | } 101 | 102 | func (r *Repository) Insert(ctx context.Context, entity ArticleTag) error { 103 | _, err := r.primaryDB.ModelContext(ctx, &entity).Insert() 104 | 105 | return specifyError(err) 106 | } 107 | 108 | func (r *Repository) Update(ctx context.Context, entity ArticleTag) error { 109 | currentVersion := entity.Version 110 | entity.Version++ 111 | 112 | res, err := r.primaryDB.ModelContext(ctx, &entity). 113 | WherePK(). 114 | Where("version = ?", currentVersion). 115 | Update() 116 | 117 | if err != nil { 118 | return specifyError(err) 119 | } 120 | 121 | if res.RowsAffected() == 0 { 122 | return NoRowsAffectedError 123 | } 124 | 125 | return nil 126 | } 127 | 128 | func specifyError(err error) error { 129 | pgErr, ok := err.(pg.Error) 130 | if !ok { 131 | return err 132 | } 133 | 134 | constraint := pgErr.Field([]byte("n")[0]) 135 | 136 | if constraint == articleIDConstraint { 137 | return ArticleIDConstraintError 138 | } 139 | 140 | if constraint == tagIDConstraint { 141 | return TagIDConstraintError 142 | } 143 | 144 | if constraint == articleIDTagIDConstraint { 145 | return ArticleIDTagIDConstraintError 146 | } 147 | 148 | if constraint == articleIDSortRankConstraint { 149 | return ArticleIDSortRankConstraintError 150 | } 151 | 152 | return err 153 | } -------------------------------------------------------------------------------- /internal/feature/image/service.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "github.com/99designs/gqlgen/graphql" 9 | "github.com/acelot/articles/internal/file" 10 | "github.com/acelot/articles/internal/go-pg/condition" 11 | "github.com/acelot/articles/internal/gql/model" 12 | "github.com/google/uuid" 13 | "io" 14 | "net/url" 15 | "time" 16 | ) 17 | 18 | var NotSupportedTypeError = errors.New("image type not supported error") 19 | var NotRecognizedError = errors.New("image not recognized error") 20 | 21 | type Service struct { 22 | repository *Repository 23 | storage *Storage 24 | } 25 | 26 | func NewService(repository *Repository, storage *Storage) *Service { 27 | return &Service{repository, storage} 28 | } 29 | 30 | func (s *Service) FindImages( 31 | filter *model.ImageFindFilterInput, 32 | sort model.ImageFindSortEnum, 33 | pageSize uint, 34 | pageNumber uint, 35 | ) ([]Image, error) { 36 | orderBy, err := mapFindSortEnumToRepositoryCondition(sort) 37 | if err != nil { 38 | return []Image{}, err 39 | } 40 | 41 | conditions := mapFindFilterInputToRepositoryConditions(filter) 42 | 43 | conditions = append( 44 | conditions, 45 | condition.Limit(pageSize), 46 | condition.Offset((pageNumber-1)*pageSize), 47 | orderBy, 48 | ) 49 | 50 | return s.repository.Find(context.Background(), conditions...) 51 | } 52 | 53 | func (s *Service) CountImages(filter *model.ImageFindFilterInput, estimate uint) (uint, error) { 54 | conditions := mapFindFilterInputToRepositoryConditions(filter) 55 | 56 | c, err := s.repository.Count(context.Background(), int(estimate), conditions...) 57 | 58 | return uint(c), err 59 | } 60 | 61 | func (s *Service) UploadImage(uploadedFile *graphql.Upload) (*Image, error) { 62 | imageBytes := make([]byte, uploadedFile.Size) 63 | 64 | bytesRead, err := io.ReadFull(uploadedFile.File, imageBytes) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | if int64(bytesRead) != uploadedFile.Size { 70 | return nil, errors.New("the number of bytes bytesRead doesn't match the content-length") 71 | } 72 | 73 | imageMeta, err := file.GetImageMeta(imageBytes) 74 | if err != nil { 75 | return nil, NotRecognizedError 76 | } 77 | 78 | if isImageTypeSupported(imageMeta.Type) == false { 79 | return nil, NotSupportedTypeError 80 | } 81 | 82 | imageID := uuid.New() 83 | createdAt := time.Now() 84 | 85 | if err := s.storage.Store( 86 | context.Background(), 87 | imageID, 88 | "image/" + imageMeta.Type, 89 | int64(bytesRead), 90 | bytes.NewReader(imageBytes), 91 | ); err != nil { 92 | return nil, err 93 | } 94 | 95 | entity := Image{ 96 | CreatedAt: createdAt, 97 | DeletedAt: nil, 98 | Height: imageMeta.Height, 99 | ID: imageID, 100 | ModifiedAt: createdAt, 101 | Type: imageMeta.Type, 102 | Version: 0, 103 | Width: imageMeta.Width, 104 | } 105 | 106 | if err := s.repository.Insert(context.Background(), entity); err != nil { 107 | return nil, err 108 | } 109 | 110 | return &entity, nil 111 | } 112 | 113 | func (s *Service) GetImageDownloadURL(imageID uuid.UUID) (*url.URL, error) { 114 | return s.storage.MakeTempURL(context.Background(), imageID, time.Hour) 115 | } 116 | 117 | func isImageTypeSupported(imageType string) bool { 118 | if imageType == "jpeg" { 119 | return true 120 | } 121 | 122 | if imageType == "png" { 123 | return true 124 | } 125 | 126 | if imageType == "webp" { 127 | return true 128 | } 129 | 130 | if imageType == "avif" { 131 | return true 132 | } 133 | 134 | return false 135 | } 136 | 137 | func mapFindFilterInputToRepositoryConditions(filter *model.ImageFindFilterInput) (conditions []condition.Condition) { 138 | if filter == nil { 139 | return 140 | } 141 | 142 | if filter.IDAnyOf != nil { 143 | conditions = append(conditions, FindFilterIDAnyOf(filter.IDAnyOf)) 144 | } 145 | 146 | return 147 | } 148 | 149 | func mapFindSortEnumToRepositoryCondition(sort model.ImageFindSortEnum) (condition.Condition, error) { 150 | switch sort { 151 | case model.ImageFindSortEnumCreatedAtAsc: 152 | return FindOrderByCreatedAt(false), nil 153 | case model.ImageFindSortEnumCreatedAtDesc: 154 | return FindOrderByCreatedAt(true), nil 155 | default: 156 | return condition.None{}, fmt.Errorf(`not mapped sort value "%s"`, sort) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /cmd/migration/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/acelot/articles/internal/feature/dbmigration" 6 | "github.com/acelot/articles/internal/migration" 7 | "github.com/docopt/docopt-go" 8 | "github.com/go-pg/pg/v10" 9 | "io/ioutil" 10 | "log" 11 | "path/filepath" 12 | "sort" 13 | "strings" 14 | "time" 15 | ) 16 | 17 | const migrationFileExt string = ".sql" 18 | 19 | const usage = `Migration 20 | 21 | Usage: 22 | migration [--dir=] update 23 | 24 | Options: 25 | -h --help Show this screen. 26 | --dir= Migrations directory [default: ./migrations].` 27 | 28 | func main() { 29 | opts, _ := docopt.ParseDoc(usage) 30 | 31 | args, err := migration.NewArgs(&opts) 32 | if err != nil { 33 | log.Fatalf("cannot parse args: %v", err) 34 | } 35 | 36 | if args.IsUpdate { 37 | updateCommand(args.Directory, args.DatabaseDsn) 38 | } 39 | } 40 | 41 | func updateCommand(directory string, databaseDsn string) { 42 | pgOpts, _ := pg.ParseURL(databaseDsn) 43 | pgOpts.PoolSize = 1 44 | 45 | // Check connection 46 | log.Print("connecting to DB...") 47 | 48 | db := pg.Connect(pgOpts) 49 | if err := db.Ping(context.Background()); err != nil { 50 | log.Fatalf("- failed: %s", databaseDsn) 51 | } 52 | 53 | log.Print("- ok") 54 | 55 | repo := dbmigration.NewRepository(db) 56 | 57 | // Check table 58 | log.Print("ensuring migration table existing...") 59 | 60 | if err := repo.EnsureTable(); err != nil { 61 | log.Fatalf("- failed: %v", err) 62 | } 63 | 64 | log.Print("- ok") 65 | 66 | // Make list of migration files 67 | log.Print("loading migration files from directory...") 68 | 69 | fileNames, err := getAllMigrationFileNames(directory) 70 | if err != nil { 71 | log.Fatalf("- failed: %v", err) 72 | } 73 | 74 | log.Printf("- %d files loaded", len(fileNames)) 75 | 76 | // Get all applied migrations from DB 77 | log.Print("loading applied migrations from DB...") 78 | 79 | migrations, err := repo.Find(context.Background(), dbmigration.FindFilterIsAppliedOnly(true)) 80 | if err != nil { 81 | log.Fatalf("- failed: %v", err) 82 | } 83 | 84 | log.Printf("- %d migrations loaded", len(migrations)) 85 | 86 | // Check migrations integrity 87 | log.Print("checking migrations integrity...") 88 | 89 | if len(migrations) > len(fileNames) { 90 | log.Fatalf("- failed: the DB contains migrations that aren't in the file system") 91 | } 92 | 93 | for i, name := range fileNames[:len(migrations)] { 94 | if name != migrations[i].Name { 95 | log.Fatalf( 96 | `- failed: migrations applying order is violated - expected "%s", actual "%s"`, 97 | name, 98 | migrations[i].Name, 99 | ) 100 | } 101 | 102 | log.Printf("- migration %s was applied on %s", name, migrations[i].AppliedAt.String()) 103 | } 104 | 105 | // Applying migrations 106 | applied := 0 107 | 108 | for _, name := range fileNames[len(migrations):] { 109 | log.Printf("applying migration %s...", name) 110 | 111 | filePath := filepath.Join(directory, name) + migrationFileExt 112 | 113 | bytes, err := ioutil.ReadFile(filePath) 114 | if err != nil { 115 | log.Fatalf("- failed: %v", err) 116 | } 117 | 118 | migrationItem := dbmigration.DBMigration{ 119 | AppliedAt: nil, 120 | Name: name, 121 | } 122 | 123 | if err := repo.Create(&migrationItem); err != nil { 124 | log.Fatalf("- failed: %v", err) 125 | } 126 | 127 | if _, err := db.Exec(string(bytes)); err != nil { 128 | log.Fatalf("- failed: %v", err) 129 | } 130 | 131 | appliedAt := time.Now() 132 | migrationItem.AppliedAt = &appliedAt 133 | 134 | if err := repo.Update(&migrationItem); err != nil { 135 | log.Fatalf("- failed: %v", err) 136 | } 137 | 138 | log.Printf("- ok") 139 | applied++ 140 | } 141 | 142 | log.Printf("- %d migrations applied", applied) 143 | } 144 | 145 | func getAllMigrationFileNames(directory string) ([]string, error) { 146 | files, err := ioutil.ReadDir(directory) 147 | if err != nil { 148 | return []string{}, err 149 | } 150 | 151 | var fileNames []string 152 | 153 | for _, file := range files { 154 | if file.IsDir() { 155 | continue 156 | } 157 | 158 | if filepath.Ext(file.Name()) != migrationFileExt { 159 | continue 160 | } 161 | 162 | fileName := strings.TrimSuffix(file.Name(), migrationFileExt) 163 | 164 | fileNames = append(fileNames, fileName) 165 | } 166 | 167 | sort.Strings(fileNames) 168 | 169 | return fileNames, nil 170 | } 171 | -------------------------------------------------------------------------------- /internal/feature/articleblock/service.go: -------------------------------------------------------------------------------- 1 | package articleblock 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/acelot/articles/internal/go-pg/condition" 8 | "github.com/acelot/articles/internal/gql/model" 9 | "github.com/google/uuid" 10 | "github.com/xissy/lexorank" 11 | "time" 12 | ) 13 | 14 | var ArticleNotFoundError = errors.New("article not found error") 15 | var InvalidSortRankError = errors.New("invalid sort rank error") 16 | var NotFoundError = errors.New("article block not found error") 17 | var TypeMismatchError = errors.New("article block type mismatch error") 18 | var VersionMismatchError = errors.New("version mismatch error") 19 | 20 | type Service struct { 21 | repository *Repository 22 | } 23 | 24 | func NewService(repository *Repository) *Service { 25 | return &Service{repository} 26 | } 27 | 28 | func (s *Service) FindArticleBlocks( 29 | filter *model.ArticleBlockFindFilterInput, 30 | sort model.ArticleBlockFindSortEnum, 31 | pageSize uint, 32 | pageNumber uint, 33 | ) ([]ArticleBlock, error) { 34 | orderBy, err := mapFindSortEnumToRepositoryCondition(sort) 35 | if err != nil { 36 | return []ArticleBlock{}, err 37 | } 38 | 39 | conditions := mapFindFilterInputToRepositoryConditions(filter) 40 | 41 | conditions = append( 42 | conditions, 43 | condition.Limit(pageSize), 44 | condition.Offset((pageNumber-1)*pageSize), 45 | orderBy, 46 | ) 47 | 48 | return s.repository.Find(context.Background(), conditions...) 49 | } 50 | 51 | func (s *Service) CountArticleBlocks(filter *model.ArticleBlockFindFilterInput, estimate uint) (uint, error) { 52 | conditions := mapFindFilterInputToRepositoryConditions(filter) 53 | 54 | c, err := s.repository.Count(context.Background(), int(estimate), conditions...) 55 | 56 | return uint(c), err 57 | } 58 | 59 | func (s *Service) CreateArticleBlock(input model.ArticleBlockCreateInput) (*ArticleBlock, error) { 60 | blockType, err := MapGqlArticleBlockTypeEnumToArticleBlockType(input.BlockType) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | newSortRank, _ := lexorank.Rank(input.SortRank.Prev, input.SortRank.Next) 66 | 67 | entity := ArticleBlock{ 68 | ArticleID: input.ArticleID, 69 | CreatedAt: time.Now(), 70 | ID: uuid.New(), 71 | ModifiedAt: time.Now(), 72 | SortRank: newSortRank, 73 | Type: blockType, 74 | Version: 0, 75 | } 76 | 77 | switch blockType { 78 | case ArticleBlockTypeHTML: 79 | entity.Data = marshalArticleBlockHTMLData(model.ArticleBlockHTMLDataInput{}) 80 | case ArticleBlockTypeImage: 81 | entity.Data = marshalArticleBlockImageData(model.ArticleBlockImageDataInput{}) 82 | default: 83 | return nil, fmt.Errorf(`unmapped ArticleBlockType "%s"`, blockType) 84 | } 85 | 86 | if err := s.repository.Insert(context.Background(), entity); err != nil { 87 | if errors.Is(err, ArticleIDConstraintError) { 88 | return nil, ArticleNotFoundError 89 | } 90 | 91 | if errors.Is(err, ArticleIDSortRankConstraintError) { 92 | return nil, InvalidSortRankError 93 | } 94 | 95 | return nil, err 96 | } 97 | 98 | return &entity, nil 99 | } 100 | 101 | func (s *Service) UpdateArticleBlock(input model.ArticleBlockUpdateInput) (*ArticleBlock, error) { 102 | entity, err := s.repository.FindOneByID(context.Background(), input.ID) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | if entity == nil { 108 | return nil, NotFoundError 109 | } 110 | 111 | if input.Data.HTML != nil { 112 | if entity.Type != ArticleBlockTypeHTML { 113 | return nil, TypeMismatchError 114 | } 115 | 116 | entity.Data = marshalArticleBlockHTMLData(*input.Data.HTML) 117 | } 118 | 119 | if input.Data.Image != nil { 120 | if entity.Type != ArticleBlockTypeImage { 121 | return nil, TypeMismatchError 122 | } 123 | 124 | entity.Data = marshalArticleBlockImageData(*input.Data.Image) 125 | } 126 | 127 | entity.Version = input.Version 128 | 129 | err = s.repository.Update(context.Background(), *entity) 130 | 131 | if errors.Is(err, NoRowsAffectedError) { 132 | return nil, VersionMismatchError 133 | } 134 | 135 | if err != nil { 136 | return nil, err 137 | } 138 | 139 | return entity, nil 140 | } 141 | 142 | func (s *Service) MoveArticleBlock(input model.ArticleBlockMoveInput) (*ArticleBlock, error) { 143 | entity, err := s.repository.FindOneByID(context.Background(), input.ID) 144 | if err != nil { 145 | return nil, err 146 | } 147 | 148 | if entity == nil { 149 | return nil, NotFoundError 150 | } 151 | 152 | newSortRank, _ := lexorank.Rank(input.SortRank.Prev, input.SortRank.Next) 153 | 154 | entity.SortRank = newSortRank 155 | entity.Version = input.Version 156 | 157 | err = s.repository.Update(context.Background(), *entity) 158 | 159 | if errors.Is(err, NoRowsAffectedError) { 160 | return nil, VersionMismatchError 161 | } 162 | 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | return entity, nil 168 | } 169 | 170 | func mapFindFilterInputToRepositoryConditions(filter *model.ArticleBlockFindFilterInput) []condition.Condition { 171 | var conditions []condition.Condition 172 | 173 | if filter == nil { 174 | return conditions 175 | } 176 | 177 | if filter.IDAnyOf != nil && len(filter.IDAnyOf) > 0 { 178 | conditions = append(conditions, FindFilterIDAnyOf(filter.IDAnyOf)) 179 | } 180 | 181 | return conditions 182 | } 183 | 184 | func mapFindSortEnumToRepositoryCondition(sort model.ArticleBlockFindSortEnum) (condition.Condition, error) { 185 | switch sort { 186 | case model.ArticleBlockFindSortEnumSortRankAsc: 187 | return FindOrderBySortRank(false), nil 188 | case model.ArticleBlockFindSortEnumSortRankDesc: 189 | return FindOrderBySortRank(true), nil 190 | case model.ArticleBlockFindSortEnumCreatedAtAsc: 191 | return FindOrderByCreatedAt(false), nil 192 | case model.ArticleBlockFindSortEnumCreatedAtDesc: 193 | return FindOrderByCreatedAt(true), nil 194 | default: 195 | return nil, fmt.Errorf("not mapped sort enum value %s", sort) 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /internal/feature/tag/loaderbyid_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. 2 | 3 | package tag 4 | 5 | import ( 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // LoaderByIDConfig captures the config to create a new LoaderByID 11 | type LoaderByIDConfig struct { 12 | // Fetch is a method that provides the data for the loader 13 | Fetch func(keys []LoaderByIDKey) ([]*Tag, []error) 14 | 15 | // Wait is how long wait before sending a batch 16 | Wait time.Duration 17 | 18 | // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit 19 | MaxBatch int 20 | } 21 | 22 | // NewLoaderByID creates a new LoaderByID given a fetch, wait, and maxBatch 23 | func NewLoaderByID(config LoaderByIDConfig) *LoaderByID { 24 | return &LoaderByID{ 25 | fetch: config.Fetch, 26 | wait: config.Wait, 27 | maxBatch: config.MaxBatch, 28 | } 29 | } 30 | 31 | // LoaderByID batches and caches requests 32 | type LoaderByID struct { 33 | // this method provides the data for the loader 34 | fetch func(keys []LoaderByIDKey) ([]*Tag, []error) 35 | 36 | // how long to done before sending a batch 37 | wait time.Duration 38 | 39 | // this will limit the maximum number of keys to send in one batch, 0 = no limit 40 | maxBatch int 41 | 42 | // INTERNAL 43 | 44 | // lazily created cache 45 | cache map[LoaderByIDKey]*Tag 46 | 47 | // the current batch. keys will continue to be collected until timeout is hit, 48 | // then everything will be sent to the fetch method and out to the listeners 49 | batch *loaderByIDBatch 50 | 51 | // mutex to prevent races 52 | mu sync.Mutex 53 | } 54 | 55 | type loaderByIDBatch struct { 56 | keys []LoaderByIDKey 57 | data []*Tag 58 | error []error 59 | closing bool 60 | done chan struct{} 61 | } 62 | 63 | // Load a Tag by key, batching and caching will be applied automatically 64 | func (l *LoaderByID) Load(key LoaderByIDKey) (*Tag, error) { 65 | return l.LoadThunk(key)() 66 | } 67 | 68 | // LoadThunk returns a function that when called will block waiting for a Tag. 69 | // This method should be used if you want one goroutine to make requests to many 70 | // different data loaders without blocking until the thunk is called. 71 | func (l *LoaderByID) LoadThunk(key LoaderByIDKey) func() (*Tag, error) { 72 | l.mu.Lock() 73 | if it, ok := l.cache[key]; ok { 74 | l.mu.Unlock() 75 | return func() (*Tag, error) { 76 | return it, nil 77 | } 78 | } 79 | if l.batch == nil { 80 | l.batch = &loaderByIDBatch{done: make(chan struct{})} 81 | } 82 | batch := l.batch 83 | pos := batch.keyIndex(l, key) 84 | l.mu.Unlock() 85 | 86 | return func() (*Tag, error) { 87 | <-batch.done 88 | 89 | var data *Tag 90 | if pos < len(batch.data) { 91 | data = batch.data[pos] 92 | } 93 | 94 | var err error 95 | // its convenient to be able to return a single error for everything 96 | if len(batch.error) == 1 { 97 | err = batch.error[0] 98 | } else if batch.error != nil { 99 | err = batch.error[pos] 100 | } 101 | 102 | if err == nil { 103 | l.mu.Lock() 104 | l.unsafeSet(key, data) 105 | l.mu.Unlock() 106 | } 107 | 108 | return data, err 109 | } 110 | } 111 | 112 | // LoadAll fetches many keys at once. It will be broken into appropriate sized 113 | // sub batches depending on how the loader is configured 114 | func (l *LoaderByID) LoadAll(keys []LoaderByIDKey) ([]*Tag, []error) { 115 | results := make([]func() (*Tag, error), len(keys)) 116 | 117 | for i, key := range keys { 118 | results[i] = l.LoadThunk(key) 119 | } 120 | 121 | tags := make([]*Tag, len(keys)) 122 | errors := make([]error, len(keys)) 123 | for i, thunk := range results { 124 | tags[i], errors[i] = thunk() 125 | } 126 | return tags, errors 127 | } 128 | 129 | // LoadAllThunk returns a function that when called will block waiting for a Tags. 130 | // This method should be used if you want one goroutine to make requests to many 131 | // different data loaders without blocking until the thunk is called. 132 | func (l *LoaderByID) LoadAllThunk(keys []LoaderByIDKey) func() ([]*Tag, []error) { 133 | results := make([]func() (*Tag, error), len(keys)) 134 | for i, key := range keys { 135 | results[i] = l.LoadThunk(key) 136 | } 137 | return func() ([]*Tag, []error) { 138 | tags := make([]*Tag, len(keys)) 139 | errors := make([]error, len(keys)) 140 | for i, thunk := range results { 141 | tags[i], errors[i] = thunk() 142 | } 143 | return tags, errors 144 | } 145 | } 146 | 147 | // Prime the cache with the provided key and value. If the key already exists, no change is made 148 | // and false is returned. 149 | // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) 150 | func (l *LoaderByID) Prime(key LoaderByIDKey, value *Tag) bool { 151 | l.mu.Lock() 152 | var found bool 153 | if _, found = l.cache[key]; !found { 154 | // make a copy when writing to the cache, its easy to pass a pointer in from a loop var 155 | // and end up with the whole cache pointing to the same value. 156 | cpy := *value 157 | l.unsafeSet(key, &cpy) 158 | } 159 | l.mu.Unlock() 160 | return !found 161 | } 162 | 163 | // Clear the value at key from the cache, if it exists 164 | func (l *LoaderByID) Clear(key LoaderByIDKey) { 165 | l.mu.Lock() 166 | delete(l.cache, key) 167 | l.mu.Unlock() 168 | } 169 | 170 | func (l *LoaderByID) unsafeSet(key LoaderByIDKey, value *Tag) { 171 | if l.cache == nil { 172 | l.cache = map[LoaderByIDKey]*Tag{} 173 | } 174 | l.cache[key] = value 175 | } 176 | 177 | // keyIndex will return the location of the key in the batch, if its not found 178 | // it will add the key to the batch 179 | func (b *loaderByIDBatch) keyIndex(l *LoaderByID, key LoaderByIDKey) int { 180 | for i, existingKey := range b.keys { 181 | if key == existingKey { 182 | return i 183 | } 184 | } 185 | 186 | pos := len(b.keys) 187 | b.keys = append(b.keys, key) 188 | if pos == 0 { 189 | go b.startTimer(l) 190 | } 191 | 192 | if l.maxBatch != 0 && pos >= l.maxBatch-1 { 193 | if !b.closing { 194 | b.closing = true 195 | l.batch = nil 196 | go b.end(l) 197 | } 198 | } 199 | 200 | return pos 201 | } 202 | 203 | func (b *loaderByIDBatch) startTimer(l *LoaderByID) { 204 | time.Sleep(l.wait) 205 | l.mu.Lock() 206 | 207 | // we must have hit a batch limit and are already finalizing this batch 208 | if b.closing { 209 | l.mu.Unlock() 210 | return 211 | } 212 | 213 | l.batch = nil 214 | l.mu.Unlock() 215 | 216 | b.end(l) 217 | } 218 | 219 | func (b *loaderByIDBatch) end(l *LoaderByID) { 220 | b.data, b.error = l.fetch(b.keys) 221 | close(b.done) 222 | } 223 | -------------------------------------------------------------------------------- /internal/feature/image/loaderbyid_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. 2 | 3 | package image 4 | 5 | import ( 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // LoaderByIDConfig captures the config to create a new LoaderByID 11 | type LoaderByIDConfig struct { 12 | // Fetch is a method that provides the data for the loader 13 | Fetch func(keys []LoaderByIDKey) ([]*Image, []error) 14 | 15 | // Wait is how long wait before sending a batch 16 | Wait time.Duration 17 | 18 | // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit 19 | MaxBatch int 20 | } 21 | 22 | // NewLoaderByID creates a new LoaderByID given a fetch, wait, and maxBatch 23 | func NewLoaderByID(config LoaderByIDConfig) *LoaderByID { 24 | return &LoaderByID{ 25 | fetch: config.Fetch, 26 | wait: config.Wait, 27 | maxBatch: config.MaxBatch, 28 | } 29 | } 30 | 31 | // LoaderByID batches and caches requests 32 | type LoaderByID struct { 33 | // this method provides the data for the loader 34 | fetch func(keys []LoaderByIDKey) ([]*Image, []error) 35 | 36 | // how long to done before sending a batch 37 | wait time.Duration 38 | 39 | // this will limit the maximum number of keys to send in one batch, 0 = no limit 40 | maxBatch int 41 | 42 | // INTERNAL 43 | 44 | // lazily created cache 45 | cache map[LoaderByIDKey]*Image 46 | 47 | // the current batch. keys will continue to be collected until timeout is hit, 48 | // then everything will be sent to the fetch method and out to the listeners 49 | batch *loaderByIDBatch 50 | 51 | // mutex to prevent races 52 | mu sync.Mutex 53 | } 54 | 55 | type loaderByIDBatch struct { 56 | keys []LoaderByIDKey 57 | data []*Image 58 | error []error 59 | closing bool 60 | done chan struct{} 61 | } 62 | 63 | // Load a Image by key, batching and caching will be applied automatically 64 | func (l *LoaderByID) Load(key LoaderByIDKey) (*Image, error) { 65 | return l.LoadThunk(key)() 66 | } 67 | 68 | // LoadThunk returns a function that when called will block waiting for a Image. 69 | // This method should be used if you want one goroutine to make requests to many 70 | // different data loaders without blocking until the thunk is called. 71 | func (l *LoaderByID) LoadThunk(key LoaderByIDKey) func() (*Image, error) { 72 | l.mu.Lock() 73 | if it, ok := l.cache[key]; ok { 74 | l.mu.Unlock() 75 | return func() (*Image, error) { 76 | return it, nil 77 | } 78 | } 79 | if l.batch == nil { 80 | l.batch = &loaderByIDBatch{done: make(chan struct{})} 81 | } 82 | batch := l.batch 83 | pos := batch.keyIndex(l, key) 84 | l.mu.Unlock() 85 | 86 | return func() (*Image, error) { 87 | <-batch.done 88 | 89 | var data *Image 90 | if pos < len(batch.data) { 91 | data = batch.data[pos] 92 | } 93 | 94 | var err error 95 | // its convenient to be able to return a single error for everything 96 | if len(batch.error) == 1 { 97 | err = batch.error[0] 98 | } else if batch.error != nil { 99 | err = batch.error[pos] 100 | } 101 | 102 | if err == nil { 103 | l.mu.Lock() 104 | l.unsafeSet(key, data) 105 | l.mu.Unlock() 106 | } 107 | 108 | return data, err 109 | } 110 | } 111 | 112 | // LoadAll fetches many keys at once. It will be broken into appropriate sized 113 | // sub batches depending on how the loader is configured 114 | func (l *LoaderByID) LoadAll(keys []LoaderByIDKey) ([]*Image, []error) { 115 | results := make([]func() (*Image, error), len(keys)) 116 | 117 | for i, key := range keys { 118 | results[i] = l.LoadThunk(key) 119 | } 120 | 121 | images := make([]*Image, len(keys)) 122 | errors := make([]error, len(keys)) 123 | for i, thunk := range results { 124 | images[i], errors[i] = thunk() 125 | } 126 | return images, errors 127 | } 128 | 129 | // LoadAllThunk returns a function that when called will block waiting for a Images. 130 | // This method should be used if you want one goroutine to make requests to many 131 | // different data loaders without blocking until the thunk is called. 132 | func (l *LoaderByID) LoadAllThunk(keys []LoaderByIDKey) func() ([]*Image, []error) { 133 | results := make([]func() (*Image, error), len(keys)) 134 | for i, key := range keys { 135 | results[i] = l.LoadThunk(key) 136 | } 137 | return func() ([]*Image, []error) { 138 | images := make([]*Image, len(keys)) 139 | errors := make([]error, len(keys)) 140 | for i, thunk := range results { 141 | images[i], errors[i] = thunk() 142 | } 143 | return images, errors 144 | } 145 | } 146 | 147 | // Prime the cache with the provided key and value. If the key already exists, no change is made 148 | // and false is returned. 149 | // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) 150 | func (l *LoaderByID) Prime(key LoaderByIDKey, value *Image) bool { 151 | l.mu.Lock() 152 | var found bool 153 | if _, found = l.cache[key]; !found { 154 | // make a copy when writing to the cache, its easy to pass a pointer in from a loop var 155 | // and end up with the whole cache pointing to the same value. 156 | cpy := *value 157 | l.unsafeSet(key, &cpy) 158 | } 159 | l.mu.Unlock() 160 | return !found 161 | } 162 | 163 | // Clear the value at key from the cache, if it exists 164 | func (l *LoaderByID) Clear(key LoaderByIDKey) { 165 | l.mu.Lock() 166 | delete(l.cache, key) 167 | l.mu.Unlock() 168 | } 169 | 170 | func (l *LoaderByID) unsafeSet(key LoaderByIDKey, value *Image) { 171 | if l.cache == nil { 172 | l.cache = map[LoaderByIDKey]*Image{} 173 | } 174 | l.cache[key] = value 175 | } 176 | 177 | // keyIndex will return the location of the key in the batch, if its not found 178 | // it will add the key to the batch 179 | func (b *loaderByIDBatch) keyIndex(l *LoaderByID, key LoaderByIDKey) int { 180 | for i, existingKey := range b.keys { 181 | if key == existingKey { 182 | return i 183 | } 184 | } 185 | 186 | pos := len(b.keys) 187 | b.keys = append(b.keys, key) 188 | if pos == 0 { 189 | go b.startTimer(l) 190 | } 191 | 192 | if l.maxBatch != 0 && pos >= l.maxBatch-1 { 193 | if !b.closing { 194 | b.closing = true 195 | l.batch = nil 196 | go b.end(l) 197 | } 198 | } 199 | 200 | return pos 201 | } 202 | 203 | func (b *loaderByIDBatch) startTimer(l *LoaderByID) { 204 | time.Sleep(l.wait) 205 | l.mu.Lock() 206 | 207 | // we must have hit a batch limit and are already finalizing this batch 208 | if b.closing { 209 | l.mu.Unlock() 210 | return 211 | } 212 | 213 | l.batch = nil 214 | l.mu.Unlock() 215 | 216 | b.end(l) 217 | } 218 | 219 | func (b *loaderByIDBatch) end(l *LoaderByID) { 220 | b.data, b.error = l.fetch(b.keys) 221 | close(b.done) 222 | } 223 | -------------------------------------------------------------------------------- /internal/feature/article/loaderbyid_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. 2 | 3 | package article 4 | 5 | import ( 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // LoaderByIDConfig captures the config to create a new LoaderByID 11 | type LoaderByIDConfig struct { 12 | // Fetch is a method that provides the data for the loader 13 | Fetch func(keys []LoaderByIDKey) ([]*Article, []error) 14 | 15 | // Wait is how long wait before sending a batch 16 | Wait time.Duration 17 | 18 | // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit 19 | MaxBatch int 20 | } 21 | 22 | // NewLoaderByID creates a new LoaderByID given a fetch, wait, and maxBatch 23 | func NewLoaderByID(config LoaderByIDConfig) *LoaderByID { 24 | return &LoaderByID{ 25 | fetch: config.Fetch, 26 | wait: config.Wait, 27 | maxBatch: config.MaxBatch, 28 | } 29 | } 30 | 31 | // LoaderByID batches and caches requests 32 | type LoaderByID struct { 33 | // this method provides the data for the loader 34 | fetch func(keys []LoaderByIDKey) ([]*Article, []error) 35 | 36 | // how long to done before sending a batch 37 | wait time.Duration 38 | 39 | // this will limit the maximum number of keys to send in one batch, 0 = no limit 40 | maxBatch int 41 | 42 | // INTERNAL 43 | 44 | // lazily created cache 45 | cache map[LoaderByIDKey]*Article 46 | 47 | // the current batch. keys will continue to be collected until timeout is hit, 48 | // then everything will be sent to the fetch method and out to the listeners 49 | batch *loaderByIDBatch 50 | 51 | // mutex to prevent races 52 | mu sync.Mutex 53 | } 54 | 55 | type loaderByIDBatch struct { 56 | keys []LoaderByIDKey 57 | data []*Article 58 | error []error 59 | closing bool 60 | done chan struct{} 61 | } 62 | 63 | // Load a Article by key, batching and caching will be applied automatically 64 | func (l *LoaderByID) Load(key LoaderByIDKey) (*Article, error) { 65 | return l.LoadThunk(key)() 66 | } 67 | 68 | // LoadThunk returns a function that when called will block waiting for a Article. 69 | // This method should be used if you want one goroutine to make requests to many 70 | // different data loaders without blocking until the thunk is called. 71 | func (l *LoaderByID) LoadThunk(key LoaderByIDKey) func() (*Article, error) { 72 | l.mu.Lock() 73 | if it, ok := l.cache[key]; ok { 74 | l.mu.Unlock() 75 | return func() (*Article, error) { 76 | return it, nil 77 | } 78 | } 79 | if l.batch == nil { 80 | l.batch = &loaderByIDBatch{done: make(chan struct{})} 81 | } 82 | batch := l.batch 83 | pos := batch.keyIndex(l, key) 84 | l.mu.Unlock() 85 | 86 | return func() (*Article, error) { 87 | <-batch.done 88 | 89 | var data *Article 90 | if pos < len(batch.data) { 91 | data = batch.data[pos] 92 | } 93 | 94 | var err error 95 | // its convenient to be able to return a single error for everything 96 | if len(batch.error) == 1 { 97 | err = batch.error[0] 98 | } else if batch.error != nil { 99 | err = batch.error[pos] 100 | } 101 | 102 | if err == nil { 103 | l.mu.Lock() 104 | l.unsafeSet(key, data) 105 | l.mu.Unlock() 106 | } 107 | 108 | return data, err 109 | } 110 | } 111 | 112 | // LoadAll fetches many keys at once. It will be broken into appropriate sized 113 | // sub batches depending on how the loader is configured 114 | func (l *LoaderByID) LoadAll(keys []LoaderByIDKey) ([]*Article, []error) { 115 | results := make([]func() (*Article, error), len(keys)) 116 | 117 | for i, key := range keys { 118 | results[i] = l.LoadThunk(key) 119 | } 120 | 121 | articles := make([]*Article, len(keys)) 122 | errors := make([]error, len(keys)) 123 | for i, thunk := range results { 124 | articles[i], errors[i] = thunk() 125 | } 126 | return articles, errors 127 | } 128 | 129 | // LoadAllThunk returns a function that when called will block waiting for a Articles. 130 | // This method should be used if you want one goroutine to make requests to many 131 | // different data loaders without blocking until the thunk is called. 132 | func (l *LoaderByID) LoadAllThunk(keys []LoaderByIDKey) func() ([]*Article, []error) { 133 | results := make([]func() (*Article, error), len(keys)) 134 | for i, key := range keys { 135 | results[i] = l.LoadThunk(key) 136 | } 137 | return func() ([]*Article, []error) { 138 | articles := make([]*Article, len(keys)) 139 | errors := make([]error, len(keys)) 140 | for i, thunk := range results { 141 | articles[i], errors[i] = thunk() 142 | } 143 | return articles, errors 144 | } 145 | } 146 | 147 | // Prime the cache with the provided key and value. If the key already exists, no change is made 148 | // and false is returned. 149 | // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) 150 | func (l *LoaderByID) Prime(key LoaderByIDKey, value *Article) bool { 151 | l.mu.Lock() 152 | var found bool 153 | if _, found = l.cache[key]; !found { 154 | // make a copy when writing to the cache, its easy to pass a pointer in from a loop var 155 | // and end up with the whole cache pointing to the same value. 156 | cpy := *value 157 | l.unsafeSet(key, &cpy) 158 | } 159 | l.mu.Unlock() 160 | return !found 161 | } 162 | 163 | // Clear the value at key from the cache, if it exists 164 | func (l *LoaderByID) Clear(key LoaderByIDKey) { 165 | l.mu.Lock() 166 | delete(l.cache, key) 167 | l.mu.Unlock() 168 | } 169 | 170 | func (l *LoaderByID) unsafeSet(key LoaderByIDKey, value *Article) { 171 | if l.cache == nil { 172 | l.cache = map[LoaderByIDKey]*Article{} 173 | } 174 | l.cache[key] = value 175 | } 176 | 177 | // keyIndex will return the location of the key in the batch, if its not found 178 | // it will add the key to the batch 179 | func (b *loaderByIDBatch) keyIndex(l *LoaderByID, key LoaderByIDKey) int { 180 | for i, existingKey := range b.keys { 181 | if key == existingKey { 182 | return i 183 | } 184 | } 185 | 186 | pos := len(b.keys) 187 | b.keys = append(b.keys, key) 188 | if pos == 0 { 189 | go b.startTimer(l) 190 | } 191 | 192 | if l.maxBatch != 0 && pos >= l.maxBatch-1 { 193 | if !b.closing { 194 | b.closing = true 195 | l.batch = nil 196 | go b.end(l) 197 | } 198 | } 199 | 200 | return pos 201 | } 202 | 203 | func (b *loaderByIDBatch) startTimer(l *LoaderByID) { 204 | time.Sleep(l.wait) 205 | l.mu.Lock() 206 | 207 | // we must have hit a batch limit and are already finalizing this batch 208 | if b.closing { 209 | l.mu.Unlock() 210 | return 211 | } 212 | 213 | l.batch = nil 214 | l.mu.Unlock() 215 | 216 | b.end(l) 217 | } 218 | 219 | func (b *loaderByIDBatch) end(l *LoaderByID) { 220 | b.data, b.error = l.fetch(b.keys) 221 | close(b.done) 222 | } 223 | -------------------------------------------------------------------------------- /internal/feature/project/loaderbyid_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. 2 | 3 | package project 4 | 5 | import ( 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // LoaderByIDConfig captures the config to create a new LoaderByID 11 | type LoaderByIDConfig struct { 12 | // Fetch is a method that provides the data for the loader 13 | Fetch func(keys []LoaderByIDKey) ([]*Project, []error) 14 | 15 | // Wait is how long wait before sending a batch 16 | Wait time.Duration 17 | 18 | // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit 19 | MaxBatch int 20 | } 21 | 22 | // NewLoaderByID creates a new LoaderByID given a fetch, wait, and maxBatch 23 | func NewLoaderByID(config LoaderByIDConfig) *LoaderByID { 24 | return &LoaderByID{ 25 | fetch: config.Fetch, 26 | wait: config.Wait, 27 | maxBatch: config.MaxBatch, 28 | } 29 | } 30 | 31 | // LoaderByID batches and caches requests 32 | type LoaderByID struct { 33 | // this method provides the data for the loader 34 | fetch func(keys []LoaderByIDKey) ([]*Project, []error) 35 | 36 | // how long to done before sending a batch 37 | wait time.Duration 38 | 39 | // this will limit the maximum number of keys to send in one batch, 0 = no limit 40 | maxBatch int 41 | 42 | // INTERNAL 43 | 44 | // lazily created cache 45 | cache map[LoaderByIDKey]*Project 46 | 47 | // the current batch. keys will continue to be collected until timeout is hit, 48 | // then everything will be sent to the fetch method and out to the listeners 49 | batch *loaderByIDBatch 50 | 51 | // mutex to prevent races 52 | mu sync.Mutex 53 | } 54 | 55 | type loaderByIDBatch struct { 56 | keys []LoaderByIDKey 57 | data []*Project 58 | error []error 59 | closing bool 60 | done chan struct{} 61 | } 62 | 63 | // Load a Project by key, batching and caching will be applied automatically 64 | func (l *LoaderByID) Load(key LoaderByIDKey) (*Project, error) { 65 | return l.LoadThunk(key)() 66 | } 67 | 68 | // LoadThunk returns a function that when called will block waiting for a Project. 69 | // This method should be used if you want one goroutine to make requests to many 70 | // different data loaders without blocking until the thunk is called. 71 | func (l *LoaderByID) LoadThunk(key LoaderByIDKey) func() (*Project, error) { 72 | l.mu.Lock() 73 | if it, ok := l.cache[key]; ok { 74 | l.mu.Unlock() 75 | return func() (*Project, error) { 76 | return it, nil 77 | } 78 | } 79 | if l.batch == nil { 80 | l.batch = &loaderByIDBatch{done: make(chan struct{})} 81 | } 82 | batch := l.batch 83 | pos := batch.keyIndex(l, key) 84 | l.mu.Unlock() 85 | 86 | return func() (*Project, error) { 87 | <-batch.done 88 | 89 | var data *Project 90 | if pos < len(batch.data) { 91 | data = batch.data[pos] 92 | } 93 | 94 | var err error 95 | // its convenient to be able to return a single error for everything 96 | if len(batch.error) == 1 { 97 | err = batch.error[0] 98 | } else if batch.error != nil { 99 | err = batch.error[pos] 100 | } 101 | 102 | if err == nil { 103 | l.mu.Lock() 104 | l.unsafeSet(key, data) 105 | l.mu.Unlock() 106 | } 107 | 108 | return data, err 109 | } 110 | } 111 | 112 | // LoadAll fetches many keys at once. It will be broken into appropriate sized 113 | // sub batches depending on how the loader is configured 114 | func (l *LoaderByID) LoadAll(keys []LoaderByIDKey) ([]*Project, []error) { 115 | results := make([]func() (*Project, error), len(keys)) 116 | 117 | for i, key := range keys { 118 | results[i] = l.LoadThunk(key) 119 | } 120 | 121 | projects := make([]*Project, len(keys)) 122 | errors := make([]error, len(keys)) 123 | for i, thunk := range results { 124 | projects[i], errors[i] = thunk() 125 | } 126 | return projects, errors 127 | } 128 | 129 | // LoadAllThunk returns a function that when called will block waiting for a Projects. 130 | // This method should be used if you want one goroutine to make requests to many 131 | // different data loaders without blocking until the thunk is called. 132 | func (l *LoaderByID) LoadAllThunk(keys []LoaderByIDKey) func() ([]*Project, []error) { 133 | results := make([]func() (*Project, error), len(keys)) 134 | for i, key := range keys { 135 | results[i] = l.LoadThunk(key) 136 | } 137 | return func() ([]*Project, []error) { 138 | projects := make([]*Project, len(keys)) 139 | errors := make([]error, len(keys)) 140 | for i, thunk := range results { 141 | projects[i], errors[i] = thunk() 142 | } 143 | return projects, errors 144 | } 145 | } 146 | 147 | // Prime the cache with the provided key and value. If the key already exists, no change is made 148 | // and false is returned. 149 | // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) 150 | func (l *LoaderByID) Prime(key LoaderByIDKey, value *Project) bool { 151 | l.mu.Lock() 152 | var found bool 153 | if _, found = l.cache[key]; !found { 154 | // make a copy when writing to the cache, its easy to pass a pointer in from a loop var 155 | // and end up with the whole cache pointing to the same value. 156 | cpy := *value 157 | l.unsafeSet(key, &cpy) 158 | } 159 | l.mu.Unlock() 160 | return !found 161 | } 162 | 163 | // Clear the value at key from the cache, if it exists 164 | func (l *LoaderByID) Clear(key LoaderByIDKey) { 165 | l.mu.Lock() 166 | delete(l.cache, key) 167 | l.mu.Unlock() 168 | } 169 | 170 | func (l *LoaderByID) unsafeSet(key LoaderByIDKey, value *Project) { 171 | if l.cache == nil { 172 | l.cache = map[LoaderByIDKey]*Project{} 173 | } 174 | l.cache[key] = value 175 | } 176 | 177 | // keyIndex will return the location of the key in the batch, if its not found 178 | // it will add the key to the batch 179 | func (b *loaderByIDBatch) keyIndex(l *LoaderByID, key LoaderByIDKey) int { 180 | for i, existingKey := range b.keys { 181 | if key == existingKey { 182 | return i 183 | } 184 | } 185 | 186 | pos := len(b.keys) 187 | b.keys = append(b.keys, key) 188 | if pos == 0 { 189 | go b.startTimer(l) 190 | } 191 | 192 | if l.maxBatch != 0 && pos >= l.maxBatch-1 { 193 | if !b.closing { 194 | b.closing = true 195 | l.batch = nil 196 | go b.end(l) 197 | } 198 | } 199 | 200 | return pos 201 | } 202 | 203 | func (b *loaderByIDBatch) startTimer(l *LoaderByID) { 204 | time.Sleep(l.wait) 205 | l.mu.Lock() 206 | 207 | // we must have hit a batch limit and are already finalizing this batch 208 | if b.closing { 209 | l.mu.Unlock() 210 | return 211 | } 212 | 213 | l.batch = nil 214 | l.mu.Unlock() 215 | 216 | b.end(l) 217 | } 218 | 219 | func (b *loaderByIDBatch) end(l *LoaderByID) { 220 | b.data, b.error = l.fetch(b.keys) 221 | close(b.done) 222 | } 223 | -------------------------------------------------------------------------------- /internal/feature/articletag/loaderbyarticleid_gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/vektah/dataloaden, DO NOT EDIT. 2 | 3 | package articletag 4 | 5 | import ( 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // LoaderByArticleIDConfig captures the config to create a new LoaderByArticleID 11 | type LoaderByArticleIDConfig struct { 12 | // Fetch is a method that provides the data for the loader 13 | Fetch func(keys []LoaderByArticleIDKey) ([][]ArticleTag, []error) 14 | 15 | // Wait is how long wait before sending a batch 16 | Wait time.Duration 17 | 18 | // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit 19 | MaxBatch int 20 | } 21 | 22 | // NewLoaderByArticleID creates a new LoaderByArticleID given a fetch, wait, and maxBatch 23 | func NewLoaderByArticleID(config LoaderByArticleIDConfig) *LoaderByArticleID { 24 | return &LoaderByArticleID{ 25 | fetch: config.Fetch, 26 | wait: config.Wait, 27 | maxBatch: config.MaxBatch, 28 | } 29 | } 30 | 31 | // LoaderByArticleID batches and caches requests 32 | type LoaderByArticleID struct { 33 | // this method provides the data for the loader 34 | fetch func(keys []LoaderByArticleIDKey) ([][]ArticleTag, []error) 35 | 36 | // how long to done before sending a batch 37 | wait time.Duration 38 | 39 | // this will limit the maximum number of keys to send in one batch, 0 = no limit 40 | maxBatch int 41 | 42 | // INTERNAL 43 | 44 | // lazily created cache 45 | cache map[LoaderByArticleIDKey][]ArticleTag 46 | 47 | // the current batch. keys will continue to be collected until timeout is hit, 48 | // then everything will be sent to the fetch method and out to the listeners 49 | batch *loaderByArticleIDBatch 50 | 51 | // mutex to prevent races 52 | mu sync.Mutex 53 | } 54 | 55 | type loaderByArticleIDBatch struct { 56 | keys []LoaderByArticleIDKey 57 | data [][]ArticleTag 58 | error []error 59 | closing bool 60 | done chan struct{} 61 | } 62 | 63 | // Load a ArticleTag by key, batching and caching will be applied automatically 64 | func (l *LoaderByArticleID) Load(key LoaderByArticleIDKey) ([]ArticleTag, error) { 65 | return l.LoadThunk(key)() 66 | } 67 | 68 | // LoadThunk returns a function that when called will block waiting for a ArticleTag. 69 | // This method should be used if you want one goroutine to make requests to many 70 | // different data loaders without blocking until the thunk is called. 71 | func (l *LoaderByArticleID) LoadThunk(key LoaderByArticleIDKey) func() ([]ArticleTag, error) { 72 | l.mu.Lock() 73 | if it, ok := l.cache[key]; ok { 74 | l.mu.Unlock() 75 | return func() ([]ArticleTag, error) { 76 | return it, nil 77 | } 78 | } 79 | if l.batch == nil { 80 | l.batch = &loaderByArticleIDBatch{done: make(chan struct{})} 81 | } 82 | batch := l.batch 83 | pos := batch.keyIndex(l, key) 84 | l.mu.Unlock() 85 | 86 | return func() ([]ArticleTag, error) { 87 | <-batch.done 88 | 89 | var data []ArticleTag 90 | if pos < len(batch.data) { 91 | data = batch.data[pos] 92 | } 93 | 94 | var err error 95 | // its convenient to be able to return a single error for everything 96 | if len(batch.error) == 1 { 97 | err = batch.error[0] 98 | } else if batch.error != nil { 99 | err = batch.error[pos] 100 | } 101 | 102 | if err == nil { 103 | l.mu.Lock() 104 | l.unsafeSet(key, data) 105 | l.mu.Unlock() 106 | } 107 | 108 | return data, err 109 | } 110 | } 111 | 112 | // LoadAll fetches many keys at once. It will be broken into appropriate sized 113 | // sub batches depending on how the loader is configured 114 | func (l *LoaderByArticleID) LoadAll(keys []LoaderByArticleIDKey) ([][]ArticleTag, []error) { 115 | results := make([]func() ([]ArticleTag, error), len(keys)) 116 | 117 | for i, key := range keys { 118 | results[i] = l.LoadThunk(key) 119 | } 120 | 121 | articleTags := make([][]ArticleTag, len(keys)) 122 | errors := make([]error, len(keys)) 123 | for i, thunk := range results { 124 | articleTags[i], errors[i] = thunk() 125 | } 126 | return articleTags, errors 127 | } 128 | 129 | // LoadAllThunk returns a function that when called will block waiting for a ArticleTags. 130 | // This method should be used if you want one goroutine to make requests to many 131 | // different data loaders without blocking until the thunk is called. 132 | func (l *LoaderByArticleID) LoadAllThunk(keys []LoaderByArticleIDKey) func() ([][]ArticleTag, []error) { 133 | results := make([]func() ([]ArticleTag, error), len(keys)) 134 | for i, key := range keys { 135 | results[i] = l.LoadThunk(key) 136 | } 137 | return func() ([][]ArticleTag, []error) { 138 | articleTags := make([][]ArticleTag, len(keys)) 139 | errors := make([]error, len(keys)) 140 | for i, thunk := range results { 141 | articleTags[i], errors[i] = thunk() 142 | } 143 | return articleTags, errors 144 | } 145 | } 146 | 147 | // Prime the cache with the provided key and value. If the key already exists, no change is made 148 | // and false is returned. 149 | // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) 150 | func (l *LoaderByArticleID) Prime(key LoaderByArticleIDKey, value []ArticleTag) bool { 151 | l.mu.Lock() 152 | var found bool 153 | if _, found = l.cache[key]; !found { 154 | // make a copy when writing to the cache, its easy to pass a pointer in from a loop var 155 | // and end up with the whole cache pointing to the same value. 156 | cpy := make([]ArticleTag, len(value)) 157 | copy(cpy, value) 158 | l.unsafeSet(key, cpy) 159 | } 160 | l.mu.Unlock() 161 | return !found 162 | } 163 | 164 | // Clear the value at key from the cache, if it exists 165 | func (l *LoaderByArticleID) Clear(key LoaderByArticleIDKey) { 166 | l.mu.Lock() 167 | delete(l.cache, key) 168 | l.mu.Unlock() 169 | } 170 | 171 | func (l *LoaderByArticleID) unsafeSet(key LoaderByArticleIDKey, value []ArticleTag) { 172 | if l.cache == nil { 173 | l.cache = map[LoaderByArticleIDKey][]ArticleTag{} 174 | } 175 | l.cache[key] = value 176 | } 177 | 178 | // keyIndex will return the location of the key in the batch, if its not found 179 | // it will add the key to the batch 180 | func (b *loaderByArticleIDBatch) keyIndex(l *LoaderByArticleID, key LoaderByArticleIDKey) int { 181 | for i, existingKey := range b.keys { 182 | if key == existingKey { 183 | return i 184 | } 185 | } 186 | 187 | pos := len(b.keys) 188 | b.keys = append(b.keys, key) 189 | if pos == 0 { 190 | go b.startTimer(l) 191 | } 192 | 193 | if l.maxBatch != 0 && pos >= l.maxBatch-1 { 194 | if !b.closing { 195 | b.closing = true 196 | l.batch = nil 197 | go b.end(l) 198 | } 199 | } 200 | 201 | return pos 202 | } 203 | 204 | func (b *loaderByArticleIDBatch) startTimer(l *LoaderByArticleID) { 205 | time.Sleep(l.wait) 206 | l.mu.Lock() 207 | 208 | // we must have hit a batch limit and are already finalizing this batch 209 | if b.closing { 210 | l.mu.Unlock() 211 | return 212 | } 213 | 214 | l.batch = nil 215 | l.mu.Unlock() 216 | 217 | b.end(l) 218 | } 219 | 220 | func (b *loaderByArticleIDBatch) end(l *LoaderByArticleID) { 221 | b.data, b.error = l.fetch(b.keys) 222 | close(b.done) 223 | } 224 | --------------------------------------------------------------------------------