feat: add link metadata endpoints
This commit is contained in:
@@ -29,9 +29,11 @@ var PublicMethods = map[string]struct{}{
|
||||
"/memos.api.v1.IdentityProviderService/ListIdentityProviders": {},
|
||||
|
||||
// Memo Service - public memos (visibility filtering done in service layer)
|
||||
"/memos.api.v1.MemoService/GetMemo": {},
|
||||
"/memos.api.v1.MemoService/ListMemos": {},
|
||||
"/memos.api.v1.MemoService/ListMemoComments": {},
|
||||
"/memos.api.v1.MemoService/GetMemo": {},
|
||||
"/memos.api.v1.MemoService/ListMemos": {},
|
||||
"/memos.api.v1.MemoService/ListMemoComments": {},
|
||||
"/memos.api.v1.MemoService/GetLinkMetadata": {},
|
||||
"/memos.api.v1.MemoService/BatchGetLinkMetadata": {},
|
||||
|
||||
// Memo sharing - share-token endpoints require no authentication
|
||||
"/memos.api.v1.MemoService/GetMemoByShare": {},
|
||||
|
||||
@@ -27,6 +27,8 @@ func TestPublicMethodsArePublic(t *testing.T) {
|
||||
// Memo Service
|
||||
"/memos.api.v1.MemoService/GetMemo",
|
||||
"/memos.api.v1.MemoService/ListMemos",
|
||||
"/memos.api.v1.MemoService/GetLinkMetadata",
|
||||
"/memos.api.v1.MemoService/BatchGetLinkMetadata",
|
||||
}
|
||||
|
||||
for _, method := range publicMethods {
|
||||
|
||||
@@ -415,6 +415,22 @@ func (s *ConnectServiceHandler) GetMemoByShare(ctx context.Context, req *connect
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *ConnectServiceHandler) GetLinkMetadata(ctx context.Context, req *connect.Request[v1pb.GetLinkMetadataRequest]) (*connect.Response[v1pb.LinkMetadata], error) {
|
||||
resp, err := s.APIV1Service.GetLinkMetadata(ctx, req.Msg)
|
||||
if err != nil {
|
||||
return nil, convertGRPCError(err)
|
||||
}
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
func (s *ConnectServiceHandler) BatchGetLinkMetadata(ctx context.Context, req *connect.Request[v1pb.BatchGetLinkMetadataRequest]) (*connect.Response[v1pb.BatchGetLinkMetadataResponse], error) {
|
||||
resp, err := s.APIV1Service.BatchGetLinkMetadata(ctx, req.Msg)
|
||||
if err != nil {
|
||||
return nil, convertGRPCError(err)
|
||||
}
|
||||
return connect.NewResponse(resp), nil
|
||||
}
|
||||
|
||||
// AttachmentService
|
||||
|
||||
func (s *ConnectServiceHandler) CreateAttachment(ctx context.Context, req *connect.Request[v1pb.CreateAttachmentRequest]) (*connect.Response[v1pb.Attachment], error) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/usememos/memos/internal/httpgetter"
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
)
|
||||
|
||||
func TestGetLinkMetadata(t *testing.T) {
|
||||
originalFetchHTMLMeta := fetchHTMLMeta
|
||||
t.Cleanup(func() {
|
||||
fetchHTMLMeta = originalFetchHTMLMeta
|
||||
})
|
||||
|
||||
fetchHTMLMeta = func(url string) (*httpgetter.HTMLMeta, error) {
|
||||
require.Equal(t, "https://example.com/article", url)
|
||||
return &httpgetter.HTMLMeta{
|
||||
Title: "Example title",
|
||||
Description: "Example description",
|
||||
Image: "https://example.com/cover.png",
|
||||
}, nil
|
||||
}
|
||||
|
||||
metadata, err := (&APIV1Service{}).GetLinkMetadata(context.Background(), &v1pb.GetLinkMetadataRequest{
|
||||
Url: "https://example.com/article",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.com/article", metadata.Url)
|
||||
require.Equal(t, "Example title", metadata.Title)
|
||||
require.Equal(t, "Example description", metadata.Description)
|
||||
require.Equal(t, "https://example.com/cover.png", metadata.Image)
|
||||
}
|
||||
|
||||
func TestGetLinkMetadataEmptyURL(t *testing.T) {
|
||||
_, err := (&APIV1Service{}).GetLinkMetadata(context.Background(), &v1pb.GetLinkMetadataRequest{})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
}
|
||||
|
||||
func TestGetLinkMetadataInternalURL(t *testing.T) {
|
||||
_, err := (&APIV1Service{}).GetLinkMetadata(context.Background(), &v1pb.GetLinkMetadataRequest{
|
||||
Url: "http://192.168.0.1",
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
}
|
||||
|
||||
func TestBatchGetLinkMetadata(t *testing.T) {
|
||||
originalFetchHTMLMeta := fetchHTMLMeta
|
||||
t.Cleanup(func() {
|
||||
fetchHTMLMeta = originalFetchHTMLMeta
|
||||
})
|
||||
|
||||
var fetchedURLs []string
|
||||
fetchHTMLMeta = func(url string) (*httpgetter.HTMLMeta, error) {
|
||||
fetchedURLs = append(fetchedURLs, url)
|
||||
return &httpgetter.HTMLMeta{
|
||||
Title: fmt.Sprintf("Title for %s", url),
|
||||
Description: fmt.Sprintf("Description for %s", url),
|
||||
Image: fmt.Sprintf("%s/cover.png", url),
|
||||
}, nil
|
||||
}
|
||||
|
||||
response, err := (&APIV1Service{}).BatchGetLinkMetadata(context.Background(), &v1pb.BatchGetLinkMetadataRequest{
|
||||
Urls: []string{
|
||||
"https://example.com/one",
|
||||
"https://example.com/two",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"https://example.com/one", "https://example.com/two"}, fetchedURLs)
|
||||
require.Len(t, response.LinkMetadata, 2)
|
||||
require.Equal(t, "https://example.com/one", response.LinkMetadata[0].Url)
|
||||
require.Equal(t, "Title for https://example.com/one", response.LinkMetadata[0].Title)
|
||||
require.Equal(t, "https://example.com/two", response.LinkMetadata[1].Url)
|
||||
require.Equal(t, "Title for https://example.com/two", response.LinkMetadata[1].Title)
|
||||
}
|
||||
|
||||
func TestBatchGetLinkMetadataEmptyURLs(t *testing.T) {
|
||||
_, err := (&APIV1Service{}).BatchGetLinkMetadata(context.Background(), &v1pb.BatchGetLinkMetadataRequest{})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
}
|
||||
|
||||
func TestBatchGetLinkMetadataTooManyURLs(t *testing.T) {
|
||||
urls := make([]string, maxBatchGetLinkMetadata+1)
|
||||
for i := range urls {
|
||||
urls[i] = fmt.Sprintf("https://example.com/%d", i)
|
||||
}
|
||||
|
||||
_, err := (&APIV1Service{}).BatchGetLinkMetadata(context.Background(), &v1pb.BatchGetLinkMetadataRequest{
|
||||
Urls: urls,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, codes.InvalidArgument, status.Code(err))
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
"github.com/usememos/memos/internal/httpgetter"
|
||||
"github.com/usememos/memos/internal/webhook"
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
storepb "github.com/usememos/memos/proto/gen/store"
|
||||
@@ -24,6 +25,10 @@ import (
|
||||
// CreateMemo when it is called internally (e.g., from CreateMemoComment).
|
||||
type suppressSSEKey struct{}
|
||||
|
||||
const maxBatchGetLinkMetadata = 10
|
||||
|
||||
var fetchHTMLMeta = httpgetter.GetHTMLMeta
|
||||
|
||||
func withSuppressSSE(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, suppressSSEKey{}, true)
|
||||
}
|
||||
@@ -382,6 +387,52 @@ func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest
|
||||
return memoMessage, nil
|
||||
}
|
||||
|
||||
// GetLinkMetadata gets metadata for a link.
|
||||
func (*APIV1Service) GetLinkMetadata(_ context.Context, request *v1pb.GetLinkMetadataRequest) (*v1pb.LinkMetadata, error) {
|
||||
return getLinkMetadata(request.GetUrl())
|
||||
}
|
||||
|
||||
// BatchGetLinkMetadata gets metadata for links.
|
||||
func (*APIV1Service) BatchGetLinkMetadata(_ context.Context, request *v1pb.BatchGetLinkMetadataRequest) (*v1pb.BatchGetLinkMetadataResponse, error) {
|
||||
if len(request.Urls) == 0 {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "urls are required")
|
||||
}
|
||||
if len(request.Urls) > maxBatchGetLinkMetadata {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "too many urls (max %d)", maxBatchGetLinkMetadata)
|
||||
}
|
||||
|
||||
linkMetadata := make([]*v1pb.LinkMetadata, 0, len(request.Urls))
|
||||
for _, url := range request.Urls {
|
||||
metadata, err := getLinkMetadata(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
linkMetadata = append(linkMetadata, metadata)
|
||||
}
|
||||
|
||||
return &v1pb.BatchGetLinkMetadataResponse{
|
||||
LinkMetadata: linkMetadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getLinkMetadata(inputURL string) (*v1pb.LinkMetadata, error) {
|
||||
url := strings.TrimSpace(inputURL)
|
||||
if url == "" {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "url is required")
|
||||
}
|
||||
htmlMeta, err := fetchHTMLMeta(url)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "failed to fetch link metadata: %v", err)
|
||||
}
|
||||
|
||||
return &v1pb.LinkMetadata{
|
||||
Url: inputURL,
|
||||
Title: htmlMeta.Title,
|
||||
Description: htmlMeta.Description,
|
||||
Image: htmlMeta.Image,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoRequest) (*v1pb.Memo, error) {
|
||||
memoUID, err := ExtractMemoUIDFromName(request.Memo.Name)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user