Skip to content

Commit 5b1cfc3

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 a738da1 commit 5b1cfc3

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"
@@ -50,7 +49,6 @@ import (
5049
"go.opentelemetry.io/otel/sdk/resource"
5150
semconv "go.opentelemetry.io/otel/semconv/v1.10.0"
5251

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

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

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

541533
// FirebaseCredentials returns firebase credentials as bytes.
@@ -1527,7 +1519,7 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi
15271519
return services.NewNotificationService(
15281520
container.Logger(),
15291521
container.Tracer(),
1530-
container.FirebaseMessagingClient(),
1522+
container.FCMClient(),
15311523
container.PhoneRepository(),
15321524
container.PhoneNotificationRepository(),
15331525
container.EventDispatcher(),

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
@@ -24,15 +24,15 @@ type PhoneNotificationService struct {
2424
tracer telemetry.Tracer
2525
phoneNotificationRepository repositories.PhoneNotificationRepository
2626
phoneRepository repositories.PhoneRepository
27-
messagingClient *messaging.Client
27+
messagingClient FCMClient
2828
eventDispatcher *EventDispatcher
2929
}
3030

3131
// NewNotificationService creates a new PhoneNotificationService
3232
func NewNotificationService(
3333
logger telemetry.Logger,
3434
tracer telemetry.Tracer,
35-
messagingClient *messaging.Client,
35+
messagingClient FCMClient,
3636
phoneRepository repositories.PhoneRepository,
3737
phoneNotificationRepository repositories.PhoneNotificationRepository,
3838
dispatcher *EventDispatcher,

0 commit comments

Comments
 (0)