Commit d6a75bba authored by johnnyjoy's avatar johnnyjoy

refactor: webhook service

parent 03399a60
......@@ -9,7 +9,6 @@ import (
"time"
"github.com/pkg/errors"
"google.golang.org/protobuf/encoding/protojson"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
......@@ -19,9 +18,20 @@ var (
timeout = 30 * time.Second
)
type WebhookRequestPayload struct {
// The target URL for the webhook request.
Url string `json:"url"`
// The type of activity that triggered this webhook.
ActivityType string `json:"activityType"`
// The resource name of the creator. Format: users/{user}
Creator string `json:"creator"`
// The memo that triggered this webhook (if applicable).
Memo *v1pb.Memo `json:"memo"`
}
// Post posts the message to webhook endpoint.
func Post(requestPayload *v1pb.WebhookRequestPayload) error {
body, err := protojson.Marshal(requestPayload)
func Post(requestPayload *WebhookRequestPayload) error {
body, err := json.Marshal(requestPayload)
if err != nil {
return errors.Wrapf(err, "failed to marshal webhook request to %s", requestPayload.Url)
}
......@@ -67,7 +77,7 @@ func Post(requestPayload *v1pb.WebhookRequestPayload) error {
// PostAsync posts the message to webhook endpoint asynchronously.
// It spawns a new goroutine to handle the request and does not wait for the response.
func PostAsync(requestPayload *v1pb.WebhookRequestPayload) {
func PostAsync(requestPayload *WebhookRequestPayload) {
go func() {
if err := Post(requestPayload); err != nil {
// Since we're in a goroutine, we can only log the error
......
......@@ -2,7 +2,6 @@ syntax = "proto3";
package memos.api.v1;
import "api/v1/common.proto";
import "api/v1/memo_service.proto";
import "google/api/annotations.proto";
import "google/api/client.proto";
......@@ -10,43 +9,43 @@ import "google/api/field_behavior.proto";
import "google/api/resource.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
import "google/protobuf/timestamp.proto";
option go_package = "gen/api/v1";
service WebhookService {
// ListWebhooks returns a list of webhooks.
// ListWebhooks returns a list of webhooks for a user.
rpc ListWebhooks(ListWebhooksRequest) returns (ListWebhooksResponse) {
option (google.api.http) = {get: "/api/v1/webhooks"};
option (google.api.http) = {get: "/api/v1/{parent=users/*}/webhooks"};
option (google.api.method_signature) = "parent";
}
// GetWebhook gets a webhook by name.
rpc GetWebhook(GetWebhookRequest) returns (Webhook) {
option (google.api.http) = {get: "/api/v1/{name=webhooks/*}"};
option (google.api.http) = {get: "/api/v1/{name=users/*/webhooks/*}"};
option (google.api.method_signature) = "name";
}
// CreateWebhook creates a new webhook.
// CreateWebhook creates a new webhook for a user.
rpc CreateWebhook(CreateWebhookRequest) returns (Webhook) {
option (google.api.http) = {
post: "/api/v1/webhooks"
post: "/api/v1/{parent=users/*}/webhooks"
body: "webhook"
};
option (google.api.method_signature) = "webhook";
option (google.api.method_signature) = "parent,webhook";
}
// UpdateWebhook updates a webhook.
// UpdateWebhook updates a webhook for a user.
rpc UpdateWebhook(UpdateWebhookRequest) returns (Webhook) {
option (google.api.http) = {
patch: "/api/v1/{webhook.name=webhooks/*}"
patch: "/api/v1/{webhook.name=users/*/webhooks/*}"
body: "webhook"
};
option (google.api.method_signature) = "webhook,update_mask";
}
// DeleteWebhook deletes a webhook.
// DeleteWebhook deletes a webhook for a user.
rpc DeleteWebhook(DeleteWebhookRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {delete: "/api/v1/{name=webhooks/*}"};
option (google.api.http) = {delete: "/api/v1/{name=users/*/webhooks/*}"};
option (google.api.method_signature) = "name";
}
}
......@@ -54,46 +53,39 @@ service WebhookService {
message Webhook {
option (google.api.resource) = {
type: "memos.api.v1/Webhook"
pattern: "webhooks/{webhook}"
name_field: "name"
pattern: "users/{user}/webhooks/{webhook}"
singular: "webhook"
plural: "webhooks"
};
// The resource name of the webhook.
// Format: webhooks/{webhook}
// Format: users/{user}/webhooks/{webhook}
string name = 1 [(google.api.field_behavior) = IDENTIFIER];
// Required. The display name of the webhook.
// The display name of the webhook.
string display_name = 2 [(google.api.field_behavior) = REQUIRED];
// Required. The target URL for the webhook.
// The target URL for the webhook.
string url = 3 [(google.api.field_behavior) = REQUIRED];
}
// Output only. The resource name of the creator.
message ListWebhooksRequest {
// Required. The parent resource where webhooks are listed.
// Format: users/{user}
string creator = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
// The state of the webhook.
State state = 5 [(google.api.field_behavior) = REQUIRED];
// Output only. The creation timestamp.
google.protobuf.Timestamp create_time = 6 [(google.api.field_behavior) = OUTPUT_ONLY];
// Output only. The last update timestamp.
google.protobuf.Timestamp update_time = 7 [(google.api.field_behavior) = OUTPUT_ONLY];
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {child_type: "memos.api.v1/Webhook"}
];
}
message ListWebhooksRequest {}
message ListWebhooksResponse {
// The list of webhooks.
repeated Webhook webhooks = 1;
}
message GetWebhookRequest {
// Required. The resource name of the webhook.
// Format: webhooks/{webhook}
// Required. The resource name of the webhook to retrieve.
// Format: users/{user}/webhooks/{webhook}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Webhook"}
......@@ -101,55 +93,33 @@ message GetWebhookRequest {
}
message CreateWebhookRequest {
// Required. The webhook to create.
Webhook webhook = 1 [
// Required. The parent resource where this webhook will be created.
// Format: users/{user}
string parent = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.field_behavior) = INPUT_ONLY
(google.api.resource_reference) = {child_type: "memos.api.v1/Webhook"}
];
// Optional. The webhook ID to use for this webhook.
// If empty, a unique ID will be generated.
// Must match the pattern [a-z0-9-]+
string webhook_id = 2 [(google.api.field_behavior) = OPTIONAL];
// Required. The webhook to create.
Webhook webhook = 2 [(google.api.field_behavior) = REQUIRED];
// Optional. If set, validate the request but don't actually create the webhook.
// Optional. If set, validate the request, but do not actually create the webhook.
bool validate_only = 3 [(google.api.field_behavior) = OPTIONAL];
}
message UpdateWebhookRequest {
// Required. The webhook to update.
// Required. The webhook resource which replaces the resource on the server.
Webhook webhook = 1 [(google.api.field_behavior) = REQUIRED];
// Required. The list of fields to update.
google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = REQUIRED];
// Optional. The list of fields to update.
google.protobuf.FieldMask update_mask = 2 [(google.api.field_behavior) = OPTIONAL];
}
message DeleteWebhookRequest {
// Required. The resource name of the webhook to delete.
// Format: webhooks/{webhook}
// Format: users/{user}/webhooks/{webhook}
string name = 1 [
(google.api.field_behavior) = REQUIRED,
(google.api.resource_reference) = {type: "memos.api.v1/Webhook"}
];
}
message WebhookRequestPayload {
// The target URL for the webhook request.
string url = 1 [(google.api.field_behavior) = REQUIRED];
// The type of activity that triggered this webhook.
string activity_type = 2 [(google.api.field_behavior) = REQUIRED];
// The resource name of the creator.
// Format: users/{user}
string creator = 3 [
(google.api.field_behavior) = OUTPUT_ONLY,
(google.api.resource_reference) = {type: "memos.api.v1/User"}
];
// The creation timestamp of the activity.
google.protobuf.Timestamp create_time = 4 [(google.api.field_behavior) = OUTPUT_ONLY];
// The memo that triggered this webhook (if applicable).
Memo memo = 5 [(google.api.field_behavior) = OPTIONAL];
}
This diff is collapsed.
This diff is collapsed.
......@@ -31,15 +31,15 @@ const (
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type WebhookServiceClient interface {
// ListWebhooks returns a list of webhooks.
// ListWebhooks returns a list of webhooks for a user.
ListWebhooks(ctx context.Context, in *ListWebhooksRequest, opts ...grpc.CallOption) (*ListWebhooksResponse, error)
// GetWebhook gets a webhook by name.
GetWebhook(ctx context.Context, in *GetWebhookRequest, opts ...grpc.CallOption) (*Webhook, error)
// CreateWebhook creates a new webhook.
// CreateWebhook creates a new webhook for a user.
CreateWebhook(ctx context.Context, in *CreateWebhookRequest, opts ...grpc.CallOption) (*Webhook, error)
// UpdateWebhook updates a webhook.
// UpdateWebhook updates a webhook for a user.
UpdateWebhook(ctx context.Context, in *UpdateWebhookRequest, opts ...grpc.CallOption) (*Webhook, error)
// DeleteWebhook deletes a webhook.
// DeleteWebhook deletes a webhook for a user.
DeleteWebhook(ctx context.Context, in *DeleteWebhookRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
}
......@@ -105,15 +105,15 @@ func (c *webhookServiceClient) DeleteWebhook(ctx context.Context, in *DeleteWebh
// All implementations must embed UnimplementedWebhookServiceServer
// for forward compatibility.
type WebhookServiceServer interface {
// ListWebhooks returns a list of webhooks.
// ListWebhooks returns a list of webhooks for a user.
ListWebhooks(context.Context, *ListWebhooksRequest) (*ListWebhooksResponse, error)
// GetWebhook gets a webhook by name.
GetWebhook(context.Context, *GetWebhookRequest) (*Webhook, error)
// CreateWebhook creates a new webhook.
// CreateWebhook creates a new webhook for a user.
CreateWebhook(context.Context, *CreateWebhookRequest) (*Webhook, error)
// UpdateWebhook updates a webhook.
// UpdateWebhook updates a webhook for a user.
UpdateWebhook(context.Context, *UpdateWebhookRequest) (*Webhook, error)
// DeleteWebhook deletes a webhook.
// DeleteWebhook deletes a webhook for a user.
DeleteWebhook(context.Context, *DeleteWebhookRequest) (*emptypb.Empty, error)
mustEmbedUnimplementedWebhookServiceServer()
}
......
This diff is collapsed.
This diff is collapsed.
......@@ -17,17 +17,19 @@ message UserSetting {
ACCESS_TOKENS = 3;
// The shortcuts of the user.
SHORTCUTS = 4;
// The webhooks of the user.
WEBHOOKS = 5;
}
int32 user_id = 1;
Key key = 2;
oneof value {
GeneralUserSetting general = 3;
SessionsUserSetting sessions = 4;
AccessTokensUserSetting access_tokens = 5;
ShortcutsUserSetting shortcuts = 6;
WebhooksUserSetting webhooks = 7;
}
}
......@@ -89,3 +91,15 @@ message ShortcutsUserSetting {
}
repeated Shortcut shortcuts = 1;
}
message WebhooksUserSetting {
message Webhook {
// Unique identifier for the webhook
string id = 1;
// Descriptive title for the webhook
string title = 2;
// The webhook URL endpoint
string url = 3;
}
repeated Webhook webhooks = 1;
}
......@@ -18,7 +18,6 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/memos/plugin/webhook"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
......@@ -689,9 +688,7 @@ func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1p
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid memo creator")
}
webhooks, err := s.Store.ListWebhooks(ctx, &store.FindWebhook{
CreatorID: &creatorID,
})
webhooks, err := s.Store.GetUserWebhooks(ctx, creatorID)
if err != nil {
return err
}
......@@ -701,7 +698,7 @@ func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1p
return errors.Wrap(err, "failed to convert memo to webhook payload")
}
payload.ActivityType = activityType
payload.Url = hook.URL
payload.Url = hook.Url
// Use asynchronous webhook dispatch
webhook.PostAsync(payload)
......@@ -709,15 +706,14 @@ func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1p
return nil
}
func convertMemoToWebhookPayload(memo *v1pb.Memo) (*v1pb.WebhookRequestPayload, error) {
func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayload, error) {
creatorID, err := ExtractUserIDFromName(memo.Creator)
if err != nil {
return nil, errors.Wrap(err, "invalid memo creator")
}
return &v1pb.WebhookRequestPayload{
Creator: fmt.Sprintf("%s%d", UserNamePrefix, creatorID),
CreateTime: timestamppb.New(time.Now()),
Memo: memo,
return &webhook.WebhookRequestPayload{
Creator: fmt.Sprintf("%s%d", UserNamePrefix, creatorID),
Memo: memo,
}, nil
}
......
......@@ -146,14 +146,18 @@ func ExtractActivityIDFromName(name string) (int32, error) {
}
// ExtractWebhookIDFromName returns the webhook ID from a resource name.
func ExtractWebhookIDFromName(name string) (int32, error) {
tokens, err := GetNameParentTokens(name, WebhookNamePrefix)
// Expected format: users/{user}/webhooks/{webhook}
func ExtractWebhookIDFromName(name string) (string, error) {
tokens, err := GetNameParentTokens(name, UserNamePrefix, WebhookNamePrefix)
if err != nil {
return 0, err
return "", err
}
id, err := util.ConvertStringToInt32(tokens[0])
if err != nil {
return 0, errors.Errorf("invalid webhook ID %q", tokens[0])
if len(tokens) != 2 {
return "", errors.Errorf("invalid webhook name format: %q", name)
}
return id, nil
webhookID := tokens[1]
if webhookID == "" {
return "", errors.Errorf("invalid webhook ID %q", webhookID)
}
return webhookID, nil
}
......@@ -13,7 +13,6 @@ import (
func TestCreateWebhook(t *testing.T) {
ctx := context.Background()
t.Run("CreateWebhook with host user", func(t *testing.T) {
// Create test service for this specific test
ts := NewTestService(t)
......@@ -27,6 +26,7 @@ func TestCreateWebhook(t *testing.T) {
// Create a webhook
req := &v1pb.CreateWebhookRequest{
Parent: fmt.Sprintf("users/%d", hostUser.ID),
Webhook: &v1pb.Webhook{
DisplayName: "Test Webhook",
Url: "https://example.com/webhook",
......@@ -41,16 +41,16 @@ func TestCreateWebhook(t *testing.T) {
require.Equal(t, "Test Webhook", resp.DisplayName)
require.Equal(t, "https://example.com/webhook", resp.Url)
require.Contains(t, resp.Name, "webhooks/")
require.Equal(t, fmt.Sprintf("users/%d", hostUser.ID), resp.Creator)
require.Contains(t, resp.Name, fmt.Sprintf("users/%d", hostUser.ID))
})
t.Run("CreateWebhook fails without authentication", func(t *testing.T) {
// Create test service for this specific test
ts := NewTestService(t)
defer ts.Cleanup()
// Try to create webhook without authentication
req := &v1pb.CreateWebhookRequest{
Parent: "users/1", // Dummy parent since we don't have a real user
Webhook: &v1pb.Webhook{
DisplayName: "Test Webhook",
Url: "https://example.com/webhook",
......@@ -73,9 +73,9 @@ func TestCreateWebhook(t *testing.T) {
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, regularUser.ID)
// Try to create webhook as regular user
req := &v1pb.CreateWebhookRequest{
Parent: fmt.Sprintf("users/%d", regularUser.ID),
Webhook: &v1pb.Webhook{
DisplayName: "Test Webhook",
Url: "https://example.com/webhook",
......@@ -99,9 +99,9 @@ func TestCreateWebhook(t *testing.T) {
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, hostUser.ID)
// Try to create webhook with missing URL
req := &v1pb.CreateWebhookRequest{
Parent: fmt.Sprintf("users/%d", hostUser.ID),
Webhook: &v1pb.Webhook{
DisplayName: "Test Webhook",
// URL missing
......@@ -128,9 +128,10 @@ func TestListWebhooks(t *testing.T) {
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, hostUser.ID)
// List webhooks
req := &v1pb.ListWebhooksRequest{}
req := &v1pb.ListWebhooksRequest{
Parent: fmt.Sprintf("users/%d", hostUser.ID),
}
resp, err := ts.Service.ListWebhooks(userCtx, req)
// Verify response
......@@ -148,9 +149,9 @@ func TestListWebhooks(t *testing.T) {
hostUser, err := ts.CreateHostUser(ctx, "admin")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, hostUser.ID)
// Create a webhook
createReq := &v1pb.CreateWebhookRequest{
Parent: fmt.Sprintf("users/%d", hostUser.ID),
Webhook: &v1pb.Webhook{
DisplayName: "Test Webhook",
Url: "https://example.com/webhook",
......@@ -160,7 +161,9 @@ func TestListWebhooks(t *testing.T) {
require.NoError(t, err)
// List webhooks
listReq := &v1pb.ListWebhooksRequest{}
listReq := &v1pb.ListWebhooksRequest{
Parent: fmt.Sprintf("users/%d", hostUser.ID),
}
resp, err := ts.Service.ListWebhooks(userCtx, listReq)
// Verify response
......@@ -175,9 +178,10 @@ func TestListWebhooks(t *testing.T) {
// Create test service for this specific test
ts := NewTestService(t)
defer ts.Cleanup()
// Try to list webhooks without authentication
req := &v1pb.ListWebhooksRequest{}
req := &v1pb.ListWebhooksRequest{
Parent: "users/1", // Dummy parent since we don't have a real user
}
_, err := ts.Service.ListWebhooks(ctx, req)
// Should fail with permission denied or unauthenticated
......@@ -197,9 +201,9 @@ func TestGetWebhook(t *testing.T) {
hostUser, err := ts.CreateHostUser(ctx, "admin")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, hostUser.ID)
// Create a webhook
createReq := &v1pb.CreateWebhookRequest{
Parent: fmt.Sprintf("users/%d", hostUser.ID),
Webhook: &v1pb.Webhook{
DisplayName: "Test Webhook",
Url: "https://example.com/webhook",
......@@ -213,13 +217,11 @@ func TestGetWebhook(t *testing.T) {
Name: createdWebhook.Name,
}
resp, err := ts.Service.GetWebhook(userCtx, getReq)
// Verify response
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, createdWebhook.Name, resp.Name)
require.Equal(t, createdWebhook.Url, resp.Url)
require.Equal(t, createdWebhook.Creator, resp.Creator)
})
t.Run("GetWebhook fails with invalid name", func(t *testing.T) {
......@@ -251,10 +253,9 @@ func TestGetWebhook(t *testing.T) {
hostUser, err := ts.CreateHostUser(ctx, "admin")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, hostUser.ID)
// Try to get non-existent webhook
req := &v1pb.GetWebhookRequest{
Name: "webhooks/999",
Name: fmt.Sprintf("users/%d/webhooks/999", hostUser.ID),
}
_, err = ts.Service.GetWebhook(userCtx, req)
......@@ -276,12 +277,12 @@ func TestUpdateWebhook(t *testing.T) {
hostUser, err := ts.CreateHostUser(ctx, "admin")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, hostUser.ID)
// Create a webhook
createReq := &v1pb.CreateWebhookRequest{
Parent: fmt.Sprintf("users/%d", hostUser.ID),
Webhook: &v1pb.Webhook{
Name: "Original Webhook",
Url: "https://example.com/webhook",
DisplayName: "Original Webhook",
Url: "https://example.com/webhook",
},
}
createdWebhook, err := ts.Service.CreateWebhook(userCtx, createReq)
......@@ -310,11 +311,10 @@ func TestUpdateWebhook(t *testing.T) {
// Create test service for this specific test
ts := NewTestService(t)
defer ts.Cleanup()
// Try to update webhook without authentication
req := &v1pb.UpdateWebhookRequest{
Webhook: &v1pb.Webhook{
Name: "webhooks/1",
Name: "users/1/webhooks/1",
Url: "https://updated.example.com/webhook",
},
}
......@@ -328,7 +328,6 @@ func TestUpdateWebhook(t *testing.T) {
func TestDeleteWebhook(t *testing.T) {
ctx := context.Background()
t.Run("DeleteWebhook removes webhook", func(t *testing.T) {
// Create test service for this specific test
ts := NewTestService(t)
......@@ -341,6 +340,7 @@ func TestDeleteWebhook(t *testing.T) {
// Create a webhook
createReq := &v1pb.CreateWebhookRequest{
Parent: fmt.Sprintf("users/%d", hostUser.ID),
Webhook: &v1pb.Webhook{
DisplayName: "Test Webhook",
Url: "https://example.com/webhook",
......@@ -373,10 +373,9 @@ func TestDeleteWebhook(t *testing.T) {
// Create test service for this specific test
ts := NewTestService(t)
defer ts.Cleanup()
// Try to delete webhook without authentication
req := &v1pb.DeleteWebhookRequest{
Name: "webhooks/1",
Name: "users/1/webhooks/1",
}
_, err := ts.Service.DeleteWebhook(ctx, req)
......@@ -394,10 +393,9 @@ func TestDeleteWebhook(t *testing.T) {
hostUser, err := ts.CreateHostUser(ctx, "admin")
require.NoError(t, err)
userCtx := ts.CreateUserContext(ctx, hostUser.ID)
// Try to delete non-existent webhook
req := &v1pb.DeleteWebhookRequest{
Name: "webhooks/999",
Name: fmt.Sprintf("users/%d/webhooks/999", hostUser.ID),
}
_, err = ts.Service.DeleteWebhook(userCtx, req)
......
This diff is collapsed.
package mysql
import (
"context"
"strings"
"github.com/usememos/memos/store"
)
func (d *DB) CreateWebhook(ctx context.Context, create *store.Webhook) (*store.Webhook, error) {
fields := []string{"`name`", "`url`", "`creator_id`"}
placeholder := []string{"?", "?", "?"}
args := []any{create.Name, create.URL, create.CreatorID}
stmt := "INSERT INTO `webhook` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ")"
result, err := d.db.ExecContext(ctx, stmt, args...)
if err != nil {
return nil, err
}
id, err := result.LastInsertId()
if err != nil {
return nil, err
}
create.ID = int32(id)
return d.GetWebhook(ctx, &store.FindWebhook{ID: &create.ID})
}
func (d *DB) ListWebhooks(ctx context.Context, find *store.FindWebhook) ([]*store.Webhook, error) {
where, args := []string{"1 = 1"}, []any{}
if find.ID != nil {
where, args = append(where, "`id` = ?"), append(args, *find.ID)
}
if find.CreatorID != nil {
where, args = append(where, "`creator_id` = ?"), append(args, *find.CreatorID)
}
rows, err := d.db.QueryContext(ctx, "SELECT `id`, UNIX_TIMESTAMP(`created_ts`), UNIX_TIMESTAMP(`updated_ts`), `creator_id`, `name`, `url` FROM `webhook` WHERE "+strings.Join(where, " AND ")+" ORDER BY `id` DESC",
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
list := []*store.Webhook{}
for rows.Next() {
webhook := &store.Webhook{}
if err := rows.Scan(
&webhook.ID,
&webhook.CreatedTs,
&webhook.UpdatedTs,
&webhook.CreatorID,
&webhook.Name,
&webhook.URL,
); err != nil {
return nil, err
}
list = append(list, webhook)
}
if err := rows.Err(); err != nil {
return nil, err
}
return list, nil
}
func (d *DB) GetWebhook(ctx context.Context, find *store.FindWebhook) (*store.Webhook, error) {
list, err := d.ListWebhooks(ctx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
return list[0], nil
}
func (d *DB) UpdateWebhook(ctx context.Context, update *store.UpdateWebhook) (*store.Webhook, error) {
set, args := []string{}, []any{}
if update.Name != nil {
set, args = append(set, "`name` = ?"), append(args, *update.Name)
}
if update.URL != nil {
set, args = append(set, "`url` = ?"), append(args, *update.URL)
}
args = append(args, update.ID)
stmt := "UPDATE `webhook` SET " + strings.Join(set, ", ") + " WHERE `id` = ?"
_, err := d.db.ExecContext(ctx, stmt, args...)
if err != nil {
return nil, err
}
webhook, err := d.GetWebhook(ctx, &store.FindWebhook{ID: &update.ID})
if err != nil {
return nil, err
}
return webhook, nil
}
func (d *DB) DeleteWebhook(ctx context.Context, delete *store.DeleteWebhook) error {
_, err := d.db.ExecContext(ctx, "DELETE FROM `webhook` WHERE `id` = ?", delete.ID)
return err
}
package postgres
import (
"context"
"strings"
"github.com/usememos/memos/store"
)
func (d *DB) CreateWebhook(ctx context.Context, create *store.Webhook) (*store.Webhook, error) {
fields := []string{"name", "url", "creator_id"}
args := []any{create.Name, create.URL, create.CreatorID}
stmt := "INSERT INTO webhook (" + strings.Join(fields, ", ") + ") VALUES (" + placeholders(len(args)) + ") RETURNING id, created_ts, updated_ts"
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
&create.ID,
&create.CreatedTs,
&create.UpdatedTs,
); err != nil {
return nil, err
}
webhook := create
return webhook, nil
}
func (d *DB) ListWebhooks(ctx context.Context, find *store.FindWebhook) ([]*store.Webhook, error) {
where, args := []string{"1 = 1"}, []any{}
if find.ID != nil {
where, args = append(where, "id = "+placeholder(len(args)+1)), append(args, *find.ID)
}
if find.CreatorID != nil {
where, args = append(where, "creator_id = "+placeholder(len(args)+1)), append(args, *find.CreatorID)
}
rows, err := d.db.QueryContext(ctx, `
SELECT
id,
created_ts,
updated_ts,
creator_id,
name,
url
FROM webhook
WHERE `+strings.Join(where, " AND ")+`
ORDER BY id DESC`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
list := []*store.Webhook{}
for rows.Next() {
webhook := &store.Webhook{}
if err := rows.Scan(
&webhook.ID,
&webhook.CreatedTs,
&webhook.UpdatedTs,
&webhook.CreatorID,
&webhook.Name,
&webhook.URL,
); err != nil {
return nil, err
}
list = append(list, webhook)
}
if err := rows.Err(); err != nil {
return nil, err
}
return list, nil
}
func (d *DB) UpdateWebhook(ctx context.Context, update *store.UpdateWebhook) (*store.Webhook, error) {
set, args := []string{}, []any{}
if update.Name != nil {
set, args = append(set, "name = "+placeholder(len(args)+1)), append(args, *update.Name)
}
if update.URL != nil {
set, args = append(set, "url = "+placeholder(len(args)+1)), append(args, *update.URL)
}
stmt := "UPDATE webhook SET " + strings.Join(set, ", ") + " WHERE id = " + placeholder(len(args)+1) + " RETURNING id, created_ts, updated_ts, creator_id, name, url"
args = append(args, update.ID)
webhook := &store.Webhook{}
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
&webhook.ID,
&webhook.CreatedTs,
&webhook.UpdatedTs,
&webhook.CreatorID,
&webhook.Name,
&webhook.URL,
); err != nil {
return nil, err
}
return webhook, nil
}
func (d *DB) DeleteWebhook(ctx context.Context, delete *store.DeleteWebhook) error {
_, err := d.db.ExecContext(ctx, "DELETE FROM webhook WHERE id = $1", delete.ID)
return err
}
package sqlite
import (
"context"
"strings"
"github.com/usememos/memos/store"
)
func (d *DB) CreateWebhook(ctx context.Context, create *store.Webhook) (*store.Webhook, error) {
fields := []string{"`name`", "`url`", "`creator_id`"}
placeholder := []string{"?", "?", "?"}
args := []any{create.Name, create.URL, create.CreatorID}
stmt := "INSERT INTO `webhook` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ") RETURNING `id`, `created_ts`, `updated_ts`"
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
&create.ID,
&create.CreatedTs,
&create.UpdatedTs,
); err != nil {
return nil, err
}
webhook := create
return webhook, nil
}
func (d *DB) ListWebhooks(ctx context.Context, find *store.FindWebhook) ([]*store.Webhook, error) {
where, args := []string{"1 = 1"}, []any{}
if find.ID != nil {
where, args = append(where, "`id` = ?"), append(args, *find.ID)
}
if find.CreatorID != nil {
where, args = append(where, "`creator_id` = ?"), append(args, *find.CreatorID)
}
rows, err := d.db.QueryContext(ctx, `
SELECT
id,
created_ts,
updated_ts,
creator_id,
name,
url
FROM webhook
WHERE `+strings.Join(where, " AND ")+`
ORDER BY id DESC`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
list := []*store.Webhook{}
for rows.Next() {
webhook := &store.Webhook{}
if err := rows.Scan(
&webhook.ID,
&webhook.CreatedTs,
&webhook.UpdatedTs,
&webhook.CreatorID,
&webhook.Name,
&webhook.URL,
); err != nil {
return nil, err
}
list = append(list, webhook)
}
if err := rows.Err(); err != nil {
return nil, err
}
return list, nil
}
func (d *DB) UpdateWebhook(ctx context.Context, update *store.UpdateWebhook) (*store.Webhook, error) {
set, args := []string{}, []any{}
if update.Name != nil {
set, args = append(set, "name = ?"), append(args, *update.Name)
}
if update.URL != nil {
set, args = append(set, "url = ?"), append(args, *update.URL)
}
args = append(args, update.ID)
stmt := "UPDATE `webhook` SET " + strings.Join(set, ", ") + " WHERE `id` = ? RETURNING `id`, `created_ts`, `updated_ts`, `creator_id`, `name`, `url`"
webhook := &store.Webhook{}
if err := d.db.QueryRowContext(ctx, stmt, args...).Scan(
&webhook.ID,
&webhook.CreatedTs,
&webhook.UpdatedTs,
&webhook.CreatorID,
&webhook.Name,
&webhook.URL,
); err != nil {
return nil, err
}
return webhook, nil
}
func (d *DB) DeleteWebhook(ctx context.Context, delete *store.DeleteWebhook) error {
_, err := d.db.ExecContext(ctx, "DELETE FROM `webhook` WHERE `id` = ?", delete.ID)
return err
}
......@@ -69,12 +69,6 @@ type Driver interface {
UpdateInbox(ctx context.Context, update *UpdateInbox) (*Inbox, error)
DeleteInbox(ctx context.Context, delete *DeleteInbox) error
// Webhook model related methods.
CreateWebhook(ctx context.Context, create *Webhook) (*Webhook, error)
ListWebhooks(ctx context.Context, find *FindWebhook) ([]*Webhook, error)
UpdateWebhook(ctx context.Context, update *UpdateWebhook) (*Webhook, error)
DeleteWebhook(ctx context.Context, delete *DeleteWebhook) error
// Reaction model related methods.
UpsertReaction(ctx context.Context, create *Reaction) (*Reaction, error)
ListReactions(ctx context.Context, find *FindReaction) ([]*Reaction, error)
......
package teststore
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/store"
)
func TestWebhookStore(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
user, err := createTestingHostUser(ctx, ts)
require.NoError(t, err)
webhook, err := ts.CreateWebhook(ctx, &store.Webhook{
CreatorID: user.ID,
Name: "test_webhook",
URL: "https://example.com",
})
require.NoError(t, err)
require.Equal(t, "test_webhook", webhook.Name)
require.Equal(t, user.ID, webhook.CreatorID)
webhooks, err := ts.ListWebhooks(ctx, &store.FindWebhook{
CreatorID: &user.ID,
})
require.NoError(t, err)
require.Equal(t, 1, len(webhooks))
require.Equal(t, webhook, webhooks[0])
newName := "test_webhook_new"
updatedWebhook, err := ts.UpdateWebhook(ctx, &store.UpdateWebhook{
ID: webhook.ID,
Name: &newName,
})
require.NoError(t, err)
require.Equal(t, newName, updatedWebhook.Name)
require.Equal(t, webhook.CreatorID, updatedWebhook.CreatorID)
err = ts.DeleteWebhook(ctx, &store.DeleteWebhook{
ID: webhook.ID,
})
require.NoError(t, err)
webhooks, err = ts.ListWebhooks(ctx, &store.FindWebhook{
CreatorID: &user.ID,
})
require.NoError(t, err)
require.Equal(t, 0, len(webhooks))
ts.Close()
}
......@@ -241,6 +241,114 @@ func (s *Store) UpdateUserSessionLastAccessed(ctx context.Context, userID int32,
return err
}
// GetUserWebhooks returns the webhooks of the user.
func (s *Store) GetUserWebhooks(ctx context.Context, userID int32) ([]*storepb.WebhooksUserSetting_Webhook, error) {
userSetting, err := s.GetUserSetting(ctx, &FindUserSetting{
UserID: &userID,
Key: storepb.UserSetting_WEBHOOKS,
})
if err != nil {
return nil, err
}
if userSetting == nil {
return []*storepb.WebhooksUserSetting_Webhook{}, nil
}
webhooksUserSetting := userSetting.GetWebhooks()
return webhooksUserSetting.Webhooks, nil
}
// AddUserWebhook adds a new webhook for the user.
func (s *Store) AddUserWebhook(ctx context.Context, userID int32, webhook *storepb.WebhooksUserSetting_Webhook) error {
existingWebhooks, err := s.GetUserWebhooks(ctx, userID)
if err != nil {
return err
}
// Check if webhook already exists, update if it does
var updatedWebhooks []*storepb.WebhooksUserSetting_Webhook
webhookExists := false
for _, existing := range existingWebhooks {
if existing.Id == webhook.Id {
updatedWebhooks = append(updatedWebhooks, webhook)
webhookExists = true
} else {
updatedWebhooks = append(updatedWebhooks, existing)
}
}
// If webhook doesn't exist, add it
if !webhookExists {
updatedWebhooks = append(updatedWebhooks, webhook)
}
_, err = s.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: userID,
Key: storepb.UserSetting_WEBHOOKS,
Value: &storepb.UserSetting_Webhooks{
Webhooks: &storepb.WebhooksUserSetting{
Webhooks: updatedWebhooks,
},
},
})
return err
}
// RemoveUserWebhook removes the webhook of the user.
func (s *Store) RemoveUserWebhook(ctx context.Context, userID int32, webhookID string) error {
oldWebhooks, err := s.GetUserWebhooks(ctx, userID)
if err != nil {
return err
}
newWebhooks := make([]*storepb.WebhooksUserSetting_Webhook, 0, len(oldWebhooks))
for _, webhook := range oldWebhooks {
if webhookID != webhook.Id {
newWebhooks = append(newWebhooks, webhook)
}
}
_, err = s.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: userID,
Key: storepb.UserSetting_WEBHOOKS,
Value: &storepb.UserSetting_Webhooks{
Webhooks: &storepb.WebhooksUserSetting{
Webhooks: newWebhooks,
},
},
})
return err
}
// UpdateUserWebhook updates an existing webhook for the user.
func (s *Store) UpdateUserWebhook(ctx context.Context, userID int32, webhook *storepb.WebhooksUserSetting_Webhook) error {
webhooks, err := s.GetUserWebhooks(ctx, userID)
if err != nil {
return err
}
for i, existing := range webhooks {
if existing.Id == webhook.Id {
webhooks[i] = webhook
break
}
}
_, err = s.UpsertUserSetting(ctx, &storepb.UserSetting{
UserId: userID,
Key: storepb.UserSetting_WEBHOOKS,
Value: &storepb.UserSetting_Webhooks{
Webhooks: &storepb.WebhooksUserSetting{
Webhooks: webhooks,
},
},
})
return err
}
func convertUserSettingFromRaw(raw *UserSetting) (*storepb.UserSetting, error) {
userSetting := &storepb.UserSetting{
UserId: raw.UserID,
......@@ -272,6 +380,12 @@ func convertUserSettingFromRaw(raw *UserSetting) (*storepb.UserSetting, error) {
return nil, err
}
userSetting.Value = &storepb.UserSetting_General{General: generalUserSetting}
case storepb.UserSetting_WEBHOOKS:
webhooksUserSetting := &storepb.WebhooksUserSetting{}
if err := protojsonUnmarshaler.Unmarshal([]byte(raw.Value), webhooksUserSetting); err != nil {
return nil, err
}
userSetting.Value = &storepb.UserSetting_Webhooks{Webhooks: webhooksUserSetting}
default:
return nil, nil
}
......@@ -313,6 +427,13 @@ func convertUserSettingToRaw(userSetting *storepb.UserSetting) (*UserSetting, er
return nil, err
}
raw.Value = string(value)
case storepb.UserSetting_WEBHOOKS:
webhooksUserSetting := userSetting.GetWebhooks()
value, err := protojson.Marshal(webhooksUserSetting)
if err != nil {
return nil, err
}
raw.Value = string(value)
default:
return nil, errors.Errorf("unsupported user setting key: %v", userSetting.Key)
}
......
package store
import (
"context"
)
type Webhook struct {
ID int32
CreatedTs int64
UpdatedTs int64
CreatorID int32
Name string
URL string
}
type FindWebhook struct {
ID *int32
CreatorID *int32
}
type UpdateWebhook struct {
ID int32
Name *string
URL *string
}
type DeleteWebhook struct {
ID int32
}
func (s *Store) CreateWebhook(ctx context.Context, create *Webhook) (*Webhook, error) {
return s.driver.CreateWebhook(ctx, create)
}
func (s *Store) ListWebhooks(ctx context.Context, find *FindWebhook) ([]*Webhook, error) {
return s.driver.ListWebhooks(ctx, find)
}
func (s *Store) GetWebhook(ctx context.Context, find *FindWebhook) (*Webhook, error) {
list, err := s.ListWebhooks(ctx, find)
if err != nil {
return nil, err
}
if len(list) == 0 {
return nil, nil
}
return list[0], nil
}
func (s *Store) UpdateWebhook(ctx context.Context, update *UpdateWebhook) (*Webhook, error) {
return s.driver.UpdateWebhook(ctx, update)
}
func (s *Store) DeleteWebhook(ctx context.Context, delete *DeleteWebhook) error {
return s.driver.DeleteWebhook(ctx, delete)
}
......@@ -3,6 +3,7 @@ import { XIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import { webhookServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import useLoading from "@/hooks/useLoading";
import { useTranslate } from "@/utils/i18n";
import { generateDialog } from "./Dialog";
......@@ -20,6 +21,7 @@ interface State {
const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
const { webhookName, destroy, onConfirm } = props;
const t = useTranslate();
const currentUser = useCurrentUser();
const [state, setState] = useState({
displayName: "",
url: "",
......@@ -67,9 +69,15 @@ const CreateWebhookDialog: React.FC<Props> = (props: Props) => {
return;
}
if (!currentUser) {
toast.error("User not authenticated");
return;
}
try {
if (isCreating) {
await webhookServiceClient.createWebhook({
parent: currentUser.name,
webhook: {
displayName: state.displayName,
url: state.url,
......
......@@ -3,26 +3,31 @@ import { ExternalLinkIcon, TrashIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { webhookServiceClient } from "@/grpcweb";
import useCurrentUser from "@/hooks/useCurrentUser";
import { Webhook } from "@/types/proto/api/v1/webhook_service";
import { useTranslate } from "@/utils/i18n";
import showCreateWebhookDialog from "../CreateWebhookDialog";
const listWebhooks = async () => {
const { webhooks } = await webhookServiceClient.listWebhooks({});
return webhooks;
};
const WebhookSection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
const listWebhooks = async () => {
if (!currentUser) return [];
const { webhooks } = await webhookServiceClient.listWebhooks({
parent: currentUser.name,
});
return webhooks;
};
useEffect(() => {
listWebhooks().then((webhooks) => {
setWebhooks(webhooks);
});
}, []);
}, [currentUser]);
const handleCreateAccessTokenDialogConfirm = async () => {
const handleCreateWebhookDialogConfirm = async () => {
const webhooks = await listWebhooks();
setWebhooks(webhooks);
};
......@@ -47,7 +52,7 @@ const WebhookSection = () => {
<Button
color="primary"
onClick={() => {
showCreateWebhookDialog(handleCreateAccessTokenDialogConfirm);
showCreateWebhookDialog(handleCreateWebhookDialogConfirm);
}}
>
{t("common.create")}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment