~netlandish/links-dev

links: api: add `getBaseURL` query. v1 APPLIED

Peter Sanchez: 1
 api: add `getBaseURL` query.

 10 files changed, 261 insertions(+), 12 deletions(-)
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/220/mbox | git am -3
Learn more about email & git

[PATCH links] api: add `getBaseURL` query. Export this patch

also fixed issue with a naming conflict when running `make schema`

Changelog-added: `getBaseURL` query to api
Changelog-updated: api version to 0.11.0
Changelog-fixed: naming conflict with `models.TagInput` and api
generated `TagInput`
---
 api/api_test.go               |  51 ++++++++++++
 api/graph/generated.go        | 152 ++++++++++++++++++++++++++++++++++
 api/graph/schema.graphqls     |   3 +
 api/graph/schema.resolvers.go |  46 +++++++++-
 helpers.go                    |   6 +-
 models/base_url.go            |   5 +-
 models/models.go              |   4 +-
 models/tag_link_shorts.go     |   2 +-
 models/tag_links.go           |   2 +-
 models/tag_listing.go         |   2 +-
 10 files changed, 261 insertions(+), 12 deletions(-)

diff --git a/api/api_test.go b/api/api_test.go
index 9b97a7a..edf5ab6 100644
--- a/api/api_test.go
+++ b/api/api_test.go
@@ -3578,4 +3578,55 @@ func TestAPI(t *testing.T) {
		c.NoError(err)
		c.Equal(0, len(tagLinksAAfter), "Link should have 0 tags after update with empty tags")
	})

	t.Run("get base url by hash", func(t *testing.T) {
		_, err := sq.Update("base_urls").
			Set("public_ready", true).
			Where("id = ?", 1).
			PlaceholderFormat(database.GetPlaceholderFormat()).
			RunWith(srv.DB).
			Exec()
		c.NoError(err)

		type GraphQLResponse struct {
			BaseURL *models.BaseURL `json:"getBaseURL"`
		}
		var result GraphQLResponse
		op := gqlclient.NewOperation(`query GetBaseURL($hash: String!) {
			getBaseURL(hash: $hash) {
				id
				url
				hash
				counter
				visibility
				createdOn
				updatedOn
			}
		}`)
		op.Var("hash", "abcdefg")
		err = links.Execute(ctx, op, &result)
		c.NoError(err)
		c.NotNil(result.BaseURL)
		c.Equal("http://base.com", result.BaseURL.URL)
		c.Equal("abcdefg", result.BaseURL.Hash)
	})

	t.Run("get base url not found", func(t *testing.T) {
		type GraphQLResponse struct {
			BaseURL *models.BaseURL `json:"getBaseURL"`
		}
		var result GraphQLResponse
		op := gqlclient.NewOperation(`query GetBaseURL($hash: String!) {
			getBaseURL(hash: $hash) {
				id
				url
				hash
			}
		}`)
		op.Var("hash", "nonexistent-hash")
		err := links.Execute(ctx, op, &result)
		c.Error(err)
		c.Contains(err.Error(), "BaseURL Not Found")
	})

}
diff --git a/api/graph/generated.go b/api/graph/generated.go
index 9b78c73..78b7cce 100644
--- a/api/graph/generated.go
+++ b/api/graph/generated.go
@@ -414,6 +414,7 @@ type ComplexityRoot struct {
		GetAdminOrgStats      func(childComplexity int, id int) int
		GetAdminOrganizations func(childComplexity int, input *model.GetAdminOrganizationsInput) int
		GetAuditLogs          func(childComplexity int, input *model.AuditLogInput) int
		GetBaseURL            func(childComplexity int, hash string) int
		GetBookmarks          func(childComplexity int, hash string, tags *string) int
		GetDomain             func(childComplexity int, id int) int
		GetDomains            func(childComplexity int, orgSlug *string, service *model.DomainService) int
@@ -574,6 +575,7 @@ type QueryResolver interface {
	GetPaymentHistory(ctx context.Context, input *model.GetPaymentInput) (*model.PaymentCursor, error)
	GetPopularLinks(ctx context.Context, input *model.PopularLinksInput) (*model.PopularLinkCursor, error)
	GetOrgLink(ctx context.Context, hash string) (*models.OrgLink, error)
	GetBaseURL(ctx context.Context, hash string) (*models.BaseURL, error)
	GetBookmarks(ctx context.Context, hash string, tags *string) (*model.BookmarkCursor, error)
	GetOrgLinks(ctx context.Context, input *model.GetLinkInput) (*model.OrgLinkCursor, error)
	GetTags(ctx context.Context, input model.GetTagsInput) (*model.TagCursor, error)
@@ -2481,6 +2483,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin

		return e.complexity.Query.GetAuditLogs(childComplexity, args["input"].(*model.AuditLogInput)), true

	case "Query.getBaseURL":
		if e.complexity.Query.GetBaseURL == nil {
			break
		}

		args, err := ec.field_Query_getBaseURL_args(ctx, rawArgs)
		if err != nil {
			return 0, false
		}

		return e.complexity.Query.GetBaseURL(childComplexity, args["hash"].(string)), true

	case "Query.getBookmarks":
		if e.complexity.Query.GetBookmarks == nil {
			break
@@ -3606,6 +3620,17 @@ func (ec *executionContext) field_Query_getAuditLogs_args(ctx context.Context, r
	return args, nil
}

func (ec *executionContext) field_Query_getBaseURL_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
	var err error
	args := map[string]any{}
	arg0, err := graphql.ProcessArgField(ctx, rawArgs, "hash", ec.unmarshalNString2string)
	if err != nil {
		return nil, err
	}
	args["hash"] = arg0
	return args, nil
}

func (ec *executionContext) field_Query_getBookmarks_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
	var err error
	args := map[string]any{}
@@ -18739,6 +18764,114 @@ func (ec *executionContext) fieldContext_Query_getOrgLink(ctx context.Context, f
	return fc, nil
}

func (ec *executionContext) _Query_getBaseURL(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_Query_getBaseURL(ctx, field)
	if err != nil {
		return graphql.Null
	}
	ctx = graphql.WithFieldContext(ctx, fc)
	defer func() {
		if r := recover(); r != nil {
			ec.Error(ctx, ec.Recover(ctx, r))
			ret = graphql.Null
		}
	}()
	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
		directive0 := func(rctx context.Context) (any, error) {
			ctx = rctx // use context from middleware stack in children
			return ec.resolvers.Query().GetBaseURL(rctx, fc.Args["hash"].(string))
		}

		directive1 := func(ctx context.Context) (any, error) {
			scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "LINKS")
			if err != nil {
				var zeroVal *models.BaseURL
				return zeroVal, err
			}
			kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO")
			if err != nil {
				var zeroVal *models.BaseURL
				return zeroVal, err
			}
			if ec.directives.Access == nil {
				var zeroVal *models.BaseURL
				return zeroVal, errors.New("directive access is not implemented")
			}
			return ec.directives.Access(ctx, nil, directive0, scope, kind)
		}

		tmp, err := directive1(rctx)
		if err != nil {
			return nil, graphql.ErrorOnPath(ctx, err)
		}
		if tmp == nil {
			return nil, nil
		}
		if data, ok := tmp.(*models.BaseURL); ok {
			return data, nil
		}
		return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/models.BaseURL`, tmp)
	})
	if err != nil {
		ec.Error(ctx, err)
		return graphql.Null
	}
	if resTmp == nil {
		return graphql.Null
	}
	res := resTmp.(*models.BaseURL)
	fc.Result = res
	return ec.marshalOBaseURL2ᚖlinksᚋmodelsᚐBaseURL(ctx, field.Selections, res)
}

func (ec *executionContext) fieldContext_Query_getBaseURL(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
	fc = &graphql.FieldContext{
		Object:     "Query",
		Field:      field,
		IsMethod:   true,
		IsResolver: true,
		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
			switch field.Name {
			case "id":
				return ec.fieldContext_BaseURL_id(ctx, field)
			case "title":
				return ec.fieldContext_BaseURL_title(ctx, field)
			case "url":
				return ec.fieldContext_BaseURL_url(ctx, field)
			case "counter":
				return ec.fieldContext_BaseURL_counter(ctx, field)
			case "tags":
				return ec.fieldContext_BaseURL_tags(ctx, field)
			case "publicReady":
				return ec.fieldContext_BaseURL_publicReady(ctx, field)
			case "hash":
				return ec.fieldContext_BaseURL_hash(ctx, field)
			case "data":
				return ec.fieldContext_BaseURL_data(ctx, field)
			case "visibility":
				return ec.fieldContext_BaseURL_visibility(ctx, field)
			case "createdOn":
				return ec.fieldContext_BaseURL_createdOn(ctx, field)
			case "updatedOn":
				return ec.fieldContext_BaseURL_updatedOn(ctx, field)
			}
			return nil, fmt.Errorf("no field named %q was found under type BaseURL", field.Name)
		},
	}
	defer func() {
		if r := recover(); r != nil {
			err = ec.Recover(ctx, r)
			ec.Error(ctx, err)
		}
	}()
	ctx = graphql.WithFieldContext(ctx, fc)
	if fc.Args, err = ec.field_Query_getBaseURL_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
		ec.Error(ctx, err)
		return fc, err
	}
	return fc, nil
}

func (ec *executionContext) _Query_getBookmarks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
	fc, err := ec.fieldContext_Query_getBookmarks(ctx, field)
	if err != nil {
@@ -30067,6 +30200,25 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
					func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
			}

			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
		case "getBaseURL":
			field := field

			innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) {
				defer func() {
					if r := recover(); r != nil {
						ec.Error(ctx, ec.Recover(ctx, r))
					}
				}()
				res = ec._Query_getBaseURL(ctx, field)
				return res
			}

			rrm := func(ctx context.Context) graphql.Marshaler {
				return ec.OperationContext.RootResolverMiddleware(ctx,
					func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
			}

			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
		case "getBookmarks":
			field := field
diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls
index 361ffb3..f590b98 100644
--- a/api/graph/schema.graphqls
+++ b/api/graph/schema.graphqls
@@ -870,6 +870,9 @@ type Query {
    "Returns a specific organization link"
    getOrgLink(hash: String!): OrgLink @access(scope: LINKS, kind: RO)

    "Returns BaseURL. Accepts BaseURL hash or full url"
    getBaseURL(hash: String!): BaseURL @access(scope: LINKS, kind: RO)

    "Returns saved links (bookmarks) for a given URL. Accepts BaseURL hash or full url"
    getBookmarks(hash: String!, tags: String): BookmarkCursor! @access(scope: LINKS, kind: RO)

diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go
index aab1ba5..6146a0e 100644
--- a/api/graph/schema.resolvers.go
+++ b/api/graph/schema.resolvers.go
@@ -5126,8 +5126,8 @@ func (r *qRCodeResolver) ImageURL(ctx context.Context, obj *models.QRCode) (*str
func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) {
	return &model.Version{
		Major:           0,
		Minor:           10,
		Patch:           3,
		Minor:           11,
		Patch:           0,
		DeprecationDate: nil,
	}, nil
}
@@ -5456,6 +5456,48 @@ func (r *queryResolver) GetOrgLink(ctx context.Context, hash string) (*models.Or
	return orgLinks[0], nil
}

// GetBaseURL is the resolver for the getBaseURL field.
func (r *queryResolver) GetBaseURL(ctx context.Context, hash string) (*models.BaseURL, error) {
	tokenUser := oauth2.ForContext(ctx)
	if tokenUser == nil {
		return nil, valid.ErrAuthorization
	}
	user := tokenUser.User.(*models.User)
	lang := links.GetLangFromRequest(server.EchoForContext(ctx).Request(), user)
	lt := localizer.GetLocalizer(lang)

	ctx = timezone.Context(ctx, links.GetUserTZ(user))
	validator := valid.New(ctx)

	opts := &database.FilterOptions{
		Filter: sq.Eq{"b.public_ready": true},
		Limit:  1,
	}
	_, err := url.Parse(hash)
	if err != nil {
		opts.Filter = sq.And{
			opts.Filter,
			sq.Eq{"b.url": links.StripURLFragment(hash)},
		}
	} else {
		opts.Filter = sq.And{
			opts.Filter,
			sq.Eq{"b.hash": hash},
		}
	}

	burls, err := models.GetBaseURLs(ctx, opts)
	if err != nil {
		return nil, err
	}
	if len(burls) == 0 {
		validator.Error("%s", lt.Translate("BaseURL Not Found")).
			WithCode(valid.ErrNotFoundCode)
		return nil, nil
	}
	return burls[0], nil
}

// GetBookmarks is the resolver for the getBookmarks field.
func (r *queryResolver) GetBookmarks(ctx context.Context, hash string, tags *string) (*model.BookmarkCursor, error) {
	tokenUser := oauth2.ForContext(ctx)
diff --git a/helpers.go b/helpers.go
index eb77b00..44c4c91 100644
--- a/helpers.go
+++ b/helpers.go
@@ -762,8 +762,8 @@ func LangForContext(ctx context.Context) string {
	return lang
}

func ProcessTags(ctx context.Context, tags []string) ([]models.TagInput, error) {
	tagInputs := make([]models.TagInput, 0)
func ProcessTags(ctx context.Context, tags []string) ([]models.ProcessedTag, error) {
	tagInputs := make([]models.ProcessedTag, 0)
	for _, tag := range tags {
		originalName := strings.TrimSpace(tag)
		originalName = strings.TrimPrefix(originalName, "#")
@@ -780,7 +780,7 @@ func ProcessTags(ctx context.Context, tags []string) ([]models.TagInput, error)
			if err != nil {
				return nil, err
			}
			tagInputs = append(tagInputs, models.TagInput{
			tagInputs = append(tagInputs, models.ProcessedTag{
				ID:   Tag.ID,
				Name: originalName,
			})
diff --git a/models/base_url.go b/models/base_url.go
index 5510991..140fdd0 100644
--- a/models/base_url.go
+++ b/models/base_url.go
@@ -64,7 +64,7 @@ func GetBaseURLs(ctx context.Context, opts *database.FilterOptions) ([]*BaseURL,
		q := opts.GetBuilder(nil)
		rows, err := q.
			Columns("b.id", "b.url", "b.title", "b.counter", "b.data", "b.public_ready", "b.hash",
				"b.parse_attempts", "b.last_parse_attempt", "b.created_on", "b.visibility",
				"b.parse_attempts", "b.last_parse_attempt", "b.created_on", "b.updated_on", "b.visibility",
				fmt.Sprintf("json_agg(CASE WHEN t.id IS NOT NULL THEN json_build_object('id', t.id, 'name', tl.name, 'slug', t.slug, 'createdOn', t.created_on) END ORDER BY %s)::jsonb", tagOrder)).
			From("base_urls b").
			LeftJoin("org_links ol ON ol.base_url_id = b.id").
@@ -89,7 +89,8 @@ func GetBaseURLs(ctx context.Context, opts *database.FilterOptions) ([]*BaseURL,
			var tags string
			if err = rows.Scan(&url.ID, &url.URL, &url.Title, &url.Counter,
				&url.Data, &url.PublicReady, &url.Hash, &url.ParseAttempts,
				&url.LastParseAttempt, &url.CreatedOn, &url.Visibility, &tags); err != nil {
				&url.LastParseAttempt, &url.CreatedOn, &url.UpdatedOn,
				&url.Visibility, &tags); err != nil {
				return err
			}

diff --git a/models/models.go b/models/models.go
index bb61912..87ea649 100644
--- a/models/models.go
+++ b/models/models.go
@@ -138,8 +138,8 @@ type Tag struct {
	Count int `db:"-" json:"count"`
}

// TagInput represents a tag with its ID and user's original name
type TagInput struct {
// ProcessedTag represents a tag after processing - ID resolved + user's display name
type ProcessedTag struct {
	ID   int
	Name string
}
diff --git a/models/tag_link_shorts.go b/models/tag_link_shorts.go
index 50ff3c8..b968bde 100644
--- a/models/tag_link_shorts.go
+++ b/models/tag_link_shorts.go
@@ -60,7 +60,7 @@ func GetTagLinkShort(ctx context.Context, id int) (*TagLinkShort, error) {
	return tl, err
}

func CreateBatchTagLinkShorts(ctx context.Context, linkShortID int, tags []TagInput) error {
func CreateBatchTagLinkShorts(ctx context.Context, linkShortID int, tags []ProcessedTag) error {
	if len(tags) == 0 {
		return nil
	}
diff --git a/models/tag_links.go b/models/tag_links.go
index 81f804c..7f17e65 100644
--- a/models/tag_links.go
+++ b/models/tag_links.go
@@ -60,7 +60,7 @@ func GetTagLink(ctx context.Context, id int) (*TagLink, error) {
	return tl, err
}

func CreateBatchTagLinks(ctx context.Context, linkID int, tags []TagInput) error {
func CreateBatchTagLinks(ctx context.Context, linkID int, tags []ProcessedTag) error {
	if len(tags) == 0 {
		return nil
	}
diff --git a/models/tag_listing.go b/models/tag_listing.go
index dc0939d..c543620 100644
--- a/models/tag_listing.go
+++ b/models/tag_listing.go
@@ -60,7 +60,7 @@ func GetTagListing(ctx context.Context, id int) (*TagListing, error) {
	return tl, err
}

func CreateBatchTagListings(ctx context.Context, listingID int, tags []TagInput) error {
func CreateBatchTagListings(ctx context.Context, listingID int, tags []ProcessedTag) error {
	if len(tags) == 0 {
		return nil
	}
-- 
2.52.0
Applied.

To git@git.code.netlandish.com:~netlandish/links
   3d34761..35b62fa  master -> master