Skip to content

Commit c114ea0

Browse files
AchoArnoldCopilot
andcommitted
refactor(api): replace FCM transport hack with clean FCMClient interface
Introduce FCMClient interface with two implementations: - FirebaseFCMClient: wraps the real Firebase messaging.Client - EmulatorFCMClient: sends notifications directly to phone emulator via HTTP This removes the custom HTTP transport layer (fcm_transport.go) that redirected Firebase SDK traffic. The container now switches between implementations based on FCM_ENDPOINT env var. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4b80594 commit c114ea0

6 files changed

Lines changed: 149 additions & 48 deletions

File tree

api/cmd/fcm/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ func main() {
1818
}
1919

2020
container := di.NewContainer(os.Getenv("GCP_PROJECT_ID"), "")
21-
client := container.FirebaseMessagingClient()
21+
client := container.FCMClient()
2222

2323
result, err := client.Send(context.Background(), &messaging.Message{
2424
Data: map[string]string{

api/pkg/di/container.go

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"crypto/tls"
66
"fmt"
77
"net/http"
8-
"net/url"
98
"os"
109
"strconv"
1110
"strings"
@@ -48,7 +47,6 @@ import (
4847
"go.opentelemetry.io/otel/sdk/resource"
4948
semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
5049

51-
"firebase.google.com/go/messaging"
5250
"github.com/hirosassa/zerodriver"
5351
"github.com/rs/zerolog"
5452
"go.opentelemetry.io/otel/sdk/trace"
@@ -399,25 +397,7 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
399397
func (container *Container) FirebaseApp() (app *firebase.App) {
400398
container.logger.Debug(fmt.Sprintf("creating %T", app))
401399

402-
var opts []option.ClientOption
403-
404-
if fcmEndpoint := os.Getenv("FCM_ENDPOINT"); fcmEndpoint != "" {
405-
container.logger.Info(fmt.Sprintf("using FCM endpoint override: %s", fcmEndpoint))
406-
targetURL, err := url.Parse(fcmEndpoint)
407-
if err != nil {
408-
container.logger.Fatal(stacktrace.Propagate(err, "cannot parse FCM_ENDPOINT"))
409-
}
410-
opts = append(opts, option.WithHTTPClient(&http.Client{
411-
Transport: &fcmRedirectTransport{
412-
target: targetURL,
413-
base: http.DefaultTransport,
414-
},
415-
}))
416-
}
417-
418-
opts = append(opts, option.WithCredentialsJSON(container.FirebaseCredentials()))
419-
420-
app, err := firebase.NewApp(context.Background(), nil, opts...)
400+
app, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsJSON(container.FirebaseCredentials()))
421401
if err != nil {
422402
msg := "cannot initialize firebase application"
423403
container.logger.Fatal(stacktrace.Propagate(err, msg))
@@ -526,15 +506,27 @@ func (container *Container) CloudTaskEventsQueue() (queue services.PushQueue) {
526506
)
527507
}
528508

529-
// FirebaseMessagingClient creates a new instance of messaging.Client
530-
func (container *Container) FirebaseMessagingClient() (client *messaging.Client) {
531-
container.logger.Debug(fmt.Sprintf("creating %T", client))
509+
// FCMClient creates the appropriate FCM client based on configuration.
510+
// When FCM_ENDPOINT is set, it returns an EmulatorFCMClient that sends
511+
// notifications directly to the phone emulator via HTTP.
512+
// Otherwise, it returns a FirebaseFCMClient that uses the real Firebase SDK.
513+
func (container *Container) FCMClient() services.FCMClient {
514+
if fcmEndpoint := os.Getenv("FCM_ENDPOINT"); fcmEndpoint != "" {
515+
container.logger.Info(fmt.Sprintf("using emulator FCM client with endpoint: %s", fcmEndpoint))
516+
return services.NewEmulatorFCMClient(
517+
container.HTTPClient("emulator_fcm"),
518+
fcmEndpoint,
519+
container.Logger(),
520+
)
521+
}
522+
523+
container.logger.Debug("creating FirebaseFCMClient")
532524
messagingClient, err := container.FirebaseApp().Messaging(context.Background())
533525
if err != nil {
534526
msg := "cannot initialize firebase messaging client"
535527
container.logger.Fatal(stacktrace.Propagate(err, msg))
536528
}
537-
return messagingClient
529+
return services.NewFirebaseFCMClient(messagingClient)
538530
}
539531

540532
// FirebaseCredentials returns firebase credentials as bytes.
@@ -1608,7 +1600,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
16081600
return services.NewNotificationService(
16091601
container.Logger(),
16101602
container.Tracer(),
1611-
container.FirebaseMessagingClient(),
1603+
container.FCMClient(),
16121604
container.PhoneRepository(),
16131605
container.PhoneNotificationRepository(),
16141606
container.MessageSendScheduleRepository(),

api/pkg/di/fcm_transport.go

Lines changed: 0 additions & 19 deletions
This file was deleted.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package services
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
11+
"firebase.google.com/go/messaging"
12+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
13+
"github.com/palantir/stacktrace"
14+
)
15+
16+
// EmulatorFCMClient sends FCM messages to the phone emulator via HTTP.
17+
type EmulatorFCMClient struct {
18+
httpClient *http.Client
19+
endpoint string
20+
logger telemetry.Logger
21+
}
22+
23+
// NewEmulatorFCMClient creates a new EmulatorFCMClient.
24+
func NewEmulatorFCMClient(httpClient *http.Client, endpoint string, logger telemetry.Logger) *EmulatorFCMClient {
25+
return &EmulatorFCMClient{
26+
httpClient: httpClient,
27+
endpoint: endpoint,
28+
logger: logger,
29+
}
30+
}
31+
32+
// emulatorFCMRequest is the payload sent to the emulator's FCM endpoint.
33+
type emulatorFCMRequest struct {
34+
Message *emulatorFCMMessage `json:"message"`
35+
}
36+
37+
type emulatorFCMMessage struct {
38+
Token string `json:"token"`
39+
Data map[string]string `json:"data,omitempty"`
40+
Android *emulatorAndroid `json:"android,omitempty"`
41+
}
42+
43+
type emulatorAndroid struct {
44+
Priority string `json:"priority,omitempty"`
45+
}
46+
47+
// emulatorFCMResponse is the response from the emulator.
48+
type emulatorFCMResponse struct {
49+
Name string `json:"name"`
50+
}
51+
52+
// Send sends a message to the emulator's FCM endpoint.
53+
func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) {
54+
payload := &emulatorFCMRequest{
55+
Message: &emulatorFCMMessage{
56+
Token: message.Token,
57+
Data: message.Data,
58+
},
59+
}
60+
if message.Android != nil {
61+
payload.Message.Android = &emulatorAndroid{
62+
Priority: message.Android.Priority,
63+
}
64+
}
65+
66+
body, err := json.Marshal(payload)
67+
if err != nil {
68+
return "", stacktrace.Propagate(err, "cannot marshal FCM request for emulator")
69+
}
70+
71+
url := fmt.Sprintf("%s/v1/projects/httpsms-test/messages:send", c.endpoint)
72+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
73+
if err != nil {
74+
return "", stacktrace.Propagate(err, "cannot create HTTP request for emulator FCM")
75+
}
76+
req.Header.Set("Content-Type", "application/json")
77+
78+
resp, err := c.httpClient.Do(req)
79+
if err != nil {
80+
return "", stacktrace.Propagate(err, fmt.Sprintf("cannot send FCM to emulator at [%s]", url))
81+
}
82+
defer resp.Body.Close()
83+
84+
respBody, err := io.ReadAll(resp.Body)
85+
if err != nil {
86+
return "", stacktrace.Propagate(err, "cannot read emulator FCM response body")
87+
}
88+
89+
if resp.StatusCode != http.StatusOK {
90+
return "", stacktrace.NewError("emulator FCM returned status %d: %s", resp.StatusCode, string(respBody))
91+
}
92+
93+
var result emulatorFCMResponse
94+
if err = json.Unmarshal(respBody, &result); err != nil {
95+
return "", stacktrace.Propagate(err, "cannot decode emulator FCM response")
96+
}
97+
98+
c.logger.Info(fmt.Sprintf("emulator FCM sent successfully: %s", result.Name))
99+
return result.Name, nil
100+
}

api/pkg/services/fcm_client.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package services
2+
3+
import (
4+
"context"
5+
6+
"firebase.google.com/go/messaging"
7+
)
8+
9+
// FCMClient is the interface for sending Firebase Cloud Messaging notifications.
10+
type FCMClient interface {
11+
// Send sends a message via FCM and returns the message name on success.
12+
Send(ctx context.Context, message *messaging.Message) (string, error)
13+
}
14+
15+
// FirebaseFCMClient wraps the real Firebase messaging.Client.
16+
type FirebaseFCMClient struct {
17+
client *messaging.Client
18+
}
19+
20+
// NewFirebaseFCMClient creates a new FirebaseFCMClient.
21+
func NewFirebaseFCMClient(client *messaging.Client) *FirebaseFCMClient {
22+
return &FirebaseFCMClient{client: client}
23+
}
24+
25+
// Send sends a message via the real Firebase SDK.
26+
func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) {
27+
return c.client.Send(ctx, message)
28+
}

api/pkg/services/phone_notification_service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@ type PhoneNotificationService struct {
2626
phoneNotificationRepository repositories.PhoneNotificationRepository
2727
phoneRepository repositories.PhoneRepository
2828
messageSendScheduleRepository repositories.MessageSendScheduleRepository
29-
messagingClient *messaging.Client
29+
messagingClient FCMClient
3030
eventDispatcher *EventDispatcher
3131
}
3232

3333
// NewNotificationService creates a new PhoneNotificationService
3434
func NewNotificationService(
3535
logger telemetry.Logger,
3636
tracer telemetry.Tracer,
37-
messagingClient *messaging.Client,
37+
messagingClient FCMClient,
3838
phoneRepository repositories.PhoneRepository,
3939
phoneNotificationRepository repositories.PhoneNotificationRepository,
4040
messageSendScheduleRepository repositories.MessageSendScheduleRepository,

0 commit comments

Comments
 (0)