Skip to content

Commit e66408c

Browse files
authored
feat(rc): Implementation of Server-side Remote Config
1 parent 4ca8578 commit e66408c

11 files changed

+2568
-0
lines changed

firebase.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131
"firebase.google.com/go/v4/iid"
3232
"firebase.google.com/go/v4/internal"
3333
"firebase.google.com/go/v4/messaging"
34+
"firebase.google.com/go/v4/remoteconfig"
3435
"firebase.google.com/go/v4/storage"
3536
"google.golang.org/api/option"
3637
"google.golang.org/api/transport"
@@ -138,6 +139,16 @@ func (a *App) AppCheck(ctx context.Context) (*appcheck.Client, error) {
138139
return appcheck.NewClient(ctx, conf)
139140
}
140141

142+
// RemoteConfig returns an instance of remoteconfig.Client.
143+
func (a *App) RemoteConfig(ctx context.Context) (*remoteconfig.Client, error) {
144+
conf := &internal.RemoteConfigClientConfig{
145+
ProjectID: a.projectID,
146+
Opts: a.opts,
147+
Version: Version,
148+
}
149+
return remoteconfig.NewClient(ctx, conf)
150+
}
151+
141152
// NewApp creates a new App from the provided config and client options.
142153
//
143154
// If the client options contain a valid credential (a service account file, a refresh token

internal/internal.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ type MessagingConfig struct {
7474
Version string
7575
}
7676

77+
// RemoteConfigClientConfig represents the configuration of Firebase Remote Config
78+
type RemoteConfigClientConfig struct {
79+
Opts []option.ClientOption
80+
ProjectID string
81+
Version string
82+
}
83+
7784
// AppCheckConfig represents the configuration of App Check service.
7885
type AppCheckConfig struct {
7986
ProjectID string
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
// Copyright 2025 Google Inc. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package remoteconfig
16+
17+
import (
18+
"crypto/sha256"
19+
"encoding/json"
20+
"errors"
21+
"fmt"
22+
"log"
23+
"math/big"
24+
"regexp"
25+
"strconv"
26+
"strings"
27+
)
28+
29+
type conditionEvaluator struct {
30+
evaluationContext map[string]any
31+
conditions []namedCondition
32+
}
33+
34+
const (
35+
maxConditionRecursionDepth = 10
36+
rootNestingLevel = 0
37+
doublePrecision = 64
38+
whiteSpace = " "
39+
segmentSeparator = "."
40+
maxPossibleSegments = 5
41+
)
42+
43+
var (
44+
errTooManySegments = errors.New("number of segments exceeds maximum allowed length")
45+
errNegativeSegment = errors.New("segment cannot be negative")
46+
errInvalidCustomSignal = errors.New("missing operator, key, or target values for custom signal condition")
47+
)
48+
49+
const (
50+
randomizationID = "randomizationID"
51+
totalMicroPercentiles = 100_000_000
52+
lessThanOrEqual = "LESS_OR_EQUAL"
53+
greaterThan = "GREATER_THAN"
54+
between = "BETWEEN"
55+
)
56+
57+
const (
58+
stringContains = "STRING_CONTAINS"
59+
stringDoesNotContain = "STRING_DOES_NOT_CONTAIN"
60+
stringExactlyMatches = "STRING_EXACTLY_MATCHES"
61+
stringContainsRegex = "STRING_CONTAINS_REGEX"
62+
63+
numericLessThan = "NUMERIC_LESS_THAN"
64+
numericLessThanEqual = "NUMERIC_LESS_EQUAL"
65+
numericEqual = "NUMERIC_EQUAL"
66+
numericNotEqual = "NUMERIC_NOT_EQUAL"
67+
numericGreaterThan = "NUMERIC_GREATER_THAN"
68+
numericGreaterEqual = "NUMERIC_GREATER_EQUAL"
69+
70+
semanticVersionLessThan = "SEMANTIC_VERSION_LESS_THAN"
71+
semanticVersionLessEqual = "SEMANTIC_VERSION_LESS_EQUAL"
72+
semanticVersionEqual = "SEMANTIC_VERSION_EQUAL"
73+
semanticVersionNotEqual = "SEMANTIC_VERSION_NOT_EQUAL"
74+
semanticVersionGreaterThan = "SEMANTIC_VERSION_GREATER_THAN"
75+
semanticVersionGreaterEqual = "SEMANTIC_VERSION_GREATER_EQUAL"
76+
)
77+
78+
func (ce *conditionEvaluator) evaluateConditions() map[string]bool {
79+
evaluatedConditions := make(map[string]bool)
80+
for _, condition := range ce.conditions {
81+
evaluatedConditions[condition.Name] = ce.evaluateCondition(condition.Condition, rootNestingLevel)
82+
}
83+
return evaluatedConditions
84+
}
85+
86+
func (ce *conditionEvaluator) evaluateCondition(condition *oneOfCondition, nestingLevel int) bool {
87+
if nestingLevel >= maxConditionRecursionDepth {
88+
log.Println("Maximum recursion depth is exceeded.")
89+
return false
90+
}
91+
92+
if condition.Boolean != nil {
93+
return *condition.Boolean
94+
} else if condition.OrCondition != nil {
95+
return ce.evaluateOrCondition(condition.OrCondition, nestingLevel+1)
96+
} else if condition.AndCondition != nil {
97+
return ce.evaluateAndCondition(condition.AndCondition, nestingLevel+1)
98+
} else if condition.Percent != nil {
99+
return ce.evaluatePercentCondition(condition.Percent)
100+
} else if condition.CustomSignal != nil {
101+
return ce.evaluateCustomSignalCondition(condition.CustomSignal)
102+
}
103+
log.Println("Unknown condition type encountered.")
104+
return false
105+
}
106+
107+
func (ce *conditionEvaluator) evaluateOrCondition(orCondition *orCondition, nestingLevel int) bool {
108+
for _, condition := range orCondition.Conditions {
109+
result := ce.evaluateCondition(&condition, nestingLevel+1)
110+
if result {
111+
return true
112+
}
113+
}
114+
return false
115+
}
116+
117+
func (ce *conditionEvaluator) evaluateAndCondition(andCondition *andCondition, nestingLevel int) bool {
118+
for _, condition := range andCondition.Conditions {
119+
result := ce.evaluateCondition(&condition, nestingLevel+1)
120+
if !result {
121+
return false
122+
}
123+
}
124+
return true
125+
}
126+
127+
func (ce *conditionEvaluator) evaluatePercentCondition(percentCondition *percentCondition) bool {
128+
if rid, ok := ce.evaluationContext[randomizationID].(string); ok {
129+
if percentCondition.PercentOperator == "" {
130+
log.Println("Missing percent operator for percent condition.")
131+
return false
132+
}
133+
instanceMicroPercentile := computeInstanceMicroPercentile(percentCondition.Seed, rid)
134+
switch percentCondition.PercentOperator {
135+
case lessThanOrEqual:
136+
return instanceMicroPercentile <= percentCondition.MicroPercent
137+
case greaterThan:
138+
return instanceMicroPercentile > percentCondition.MicroPercent
139+
case between:
140+
return instanceMicroPercentile > percentCondition.MicroPercentRange.MicroPercentLowerBound && instanceMicroPercentile <= percentCondition.MicroPercentRange.MicroPercentUpperBound
141+
default:
142+
log.Printf("Unknown percent operator: %s\n", percentCondition.PercentOperator)
143+
return false
144+
}
145+
}
146+
log.Println("Missing or invalid randomizationID (requires a string value) for percent condition.")
147+
return false
148+
}
149+
150+
func computeInstanceMicroPercentile(seed string, randomizationID string) uint32 {
151+
var sb strings.Builder
152+
if len(seed) > 0 {
153+
sb.WriteString(seed)
154+
sb.WriteRune('.')
155+
}
156+
sb.WriteString(randomizationID)
157+
stringToHash := sb.String()
158+
159+
hash := sha256.New()
160+
hash.Write([]byte(stringToHash))
161+
// Calculate the final SHA-256 hash as a byte slice (32 bytes).
162+
// Convert to a big.Int. The "0x" prefix is implicit in the conversion from hex to big.Int.
163+
hashBigInt := new(big.Int).SetBytes(hash.Sum(nil))
164+
instanceMicroPercentileBigInt := new(big.Int).Mod(hashBigInt, big.NewInt(totalMicroPercentiles))
165+
// Safely convert to uint32 since the range of instanceMicroPercentile is 0 to 100_000_000; range of uint32 is 0 to 4_294_967_295.
166+
return uint32(instanceMicroPercentileBigInt.Int64())
167+
}
168+
169+
func (ce *conditionEvaluator) evaluateCustomSignalCondition(customSignalCondition *customSignalCondition) bool {
170+
if err := customSignalCondition.isValid(); err != nil {
171+
log.Println(err)
172+
return false
173+
}
174+
actualValue, ok := ce.evaluationContext[customSignalCondition.CustomSignalKey]
175+
if !ok {
176+
log.Printf("Custom signal key: %s, missing from context\n", customSignalCondition.CustomSignalKey)
177+
return false
178+
}
179+
switch customSignalCondition.CustomSignalOperator {
180+
case stringContains:
181+
return compareStrings(customSignalCondition.TargetCustomSignalValues, actualValue, func(actualValue, target string) bool { return strings.Contains(actualValue, target) })
182+
case stringDoesNotContain:
183+
return !compareStrings(customSignalCondition.TargetCustomSignalValues, actualValue, func(actualValue, target string) bool { return strings.Contains(actualValue, target) })
184+
case stringExactlyMatches:
185+
return compareStrings(customSignalCondition.TargetCustomSignalValues, actualValue, func(actualValue, target string) bool {
186+
return strings.Trim(actualValue, whiteSpace) == strings.Trim(target, whiteSpace)
187+
})
188+
case stringContainsRegex:
189+
return compareStrings(customSignalCondition.TargetCustomSignalValues, actualValue, func(actualValue, targetPattern string) bool {
190+
result, err := regexp.MatchString(targetPattern, actualValue)
191+
if err != nil {
192+
return false
193+
}
194+
return result
195+
})
196+
197+
// For numeric operators only one target value is allowed.
198+
case numericLessThan:
199+
return compareNumbers(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result < 0 })
200+
case numericLessThanEqual:
201+
return compareNumbers(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result <= 0 })
202+
case numericEqual:
203+
return compareNumbers(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result == 0 })
204+
case numericNotEqual:
205+
return compareNumbers(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result != 0 })
206+
case numericGreaterThan:
207+
return compareNumbers(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result > 0 })
208+
case numericGreaterEqual:
209+
return compareNumbers(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result >= 0 })
210+
211+
// For semantic operators only one target value is allowed.
212+
case semanticVersionLessThan:
213+
return compareSemanticVersion(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result < 0 })
214+
case semanticVersionLessEqual:
215+
return compareSemanticVersion(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result <= 0 })
216+
case semanticVersionEqual:
217+
return compareSemanticVersion(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result == 0 })
218+
case semanticVersionNotEqual:
219+
return compareSemanticVersion(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result != 0 })
220+
case semanticVersionGreaterThan:
221+
return compareSemanticVersion(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result > 0 })
222+
case semanticVersionGreaterEqual:
223+
return compareSemanticVersion(customSignalCondition.TargetCustomSignalValues[0], actualValue, func(result int) bool { return result >= 0 })
224+
}
225+
log.Printf("Unknown custom signal operator: %s\n", customSignalCondition.CustomSignalOperator)
226+
return false
227+
}
228+
229+
func (cs *customSignalCondition) isValid() error {
230+
if cs.CustomSignalOperator == "" || cs.CustomSignalKey == "" || len(cs.TargetCustomSignalValues) == 0 {
231+
return errInvalidCustomSignal
232+
}
233+
return nil
234+
}
235+
236+
func compareStrings(targetCustomSignalValues []string, actualValue any, predicateFn func(actualValue, target string) bool) bool {
237+
csValStr, ok := actualValue.(string)
238+
if !ok {
239+
if jsonBytes, err := json.Marshal(actualValue); err == nil {
240+
csValStr = string(jsonBytes)
241+
} else {
242+
log.Printf("Failed to parse custom signal value '%v' as a string : %v\n", actualValue, err)
243+
return false
244+
}
245+
}
246+
for _, target := range targetCustomSignalValues {
247+
if predicateFn(csValStr, target) {
248+
return true
249+
}
250+
}
251+
return false
252+
}
253+
254+
func compareNumbers(targetCustomSignalValue string, actualValue any, predicateFn func(result int) bool) bool {
255+
targetFloat, err := strconv.ParseFloat(strings.Trim(targetCustomSignalValue, whiteSpace), doublePrecision)
256+
if err != nil {
257+
log.Printf("Failed to convert target custom signal value '%v' from string to number: %v", targetCustomSignalValue, err)
258+
return false
259+
}
260+
var actualValFloat float64
261+
switch actualValue := actualValue.(type) {
262+
case float32:
263+
actualValFloat = float64(actualValue)
264+
case float64:
265+
actualValFloat = actualValue
266+
case int8:
267+
actualValFloat = float64(actualValue)
268+
case int:
269+
actualValFloat = float64(actualValue)
270+
case int16:
271+
actualValFloat = float64(actualValue)
272+
case int32:
273+
actualValFloat = float64(actualValue)
274+
case int64:
275+
actualValFloat = float64(actualValue)
276+
case uint8:
277+
actualValFloat = float64(actualValue)
278+
case uint:
279+
actualValFloat = float64(actualValue)
280+
case uint16:
281+
actualValFloat = float64(actualValue)
282+
case uint32:
283+
actualValFloat = float64(actualValue)
284+
case uint64:
285+
actualValFloat = float64(actualValue)
286+
case bool:
287+
if actualValue {
288+
actualValFloat = 1
289+
} else {
290+
actualValFloat = 0
291+
}
292+
case string:
293+
actualValFloat, err = strconv.ParseFloat(strings.Trim(actualValue, whiteSpace), doublePrecision)
294+
if err != nil {
295+
log.Printf("Failed to convert custom signal value '%v' from string to number: %v", actualValue, err)
296+
return false
297+
}
298+
default:
299+
log.Printf("Cannot parse custom signal value '%v' of type %T as a number", actualValue, actualValue)
300+
return false
301+
}
302+
result := 0
303+
if actualValFloat > targetFloat {
304+
result = 1
305+
} else if actualValFloat < targetFloat {
306+
result = -1
307+
}
308+
return predicateFn(result)
309+
}
310+
311+
func compareSemanticVersion(targetValue string, actualValue any, predicateFn func(result int) bool) bool {
312+
targetSemVer, err := transformVersionToSegments(strings.Trim(targetValue, whiteSpace))
313+
if err != nil {
314+
log.Printf("Error transforming target semantic version %q: %v\n", targetValue, err)
315+
return false
316+
}
317+
actualValueStr := fmt.Sprintf("%v", actualValue)
318+
actualSemVer, err := transformVersionToSegments(strings.Trim(actualValueStr, whiteSpace))
319+
if err != nil {
320+
log.Printf("Error transforming custom signal value '%v' to semantic version: %v\n", actualValue, err)
321+
return false
322+
}
323+
for idx := 0; idx < maxPossibleSegments; idx++ {
324+
if actualSemVer[idx] > targetSemVer[idx] {
325+
return predicateFn(1)
326+
} else if actualSemVer[idx] < targetSemVer[idx] {
327+
return predicateFn(-1)
328+
}
329+
}
330+
return predicateFn(0)
331+
}
332+
333+
func transformVersionToSegments(version string) ([]int, error) {
334+
// Trim any trailing or leading segment separators (.) and split.
335+
trimmedVersion := strings.Trim(version, segmentSeparator)
336+
segments := strings.Split(trimmedVersion, segmentSeparator)
337+
338+
if len(segments) > maxPossibleSegments {
339+
return nil, errTooManySegments
340+
}
341+
// Initialize with the maximum possible segment length for consistent comparison.
342+
transformedVersion := make([]int, maxPossibleSegments)
343+
for idx, segmentStr := range segments {
344+
segmentInt, err := strconv.Atoi(segmentStr)
345+
if err != nil {
346+
return nil, err
347+
}
348+
if segmentInt < 0 {
349+
return nil, errNegativeSegment
350+
}
351+
transformedVersion[idx] = segmentInt
352+
}
353+
return transformedVersion, nil
354+
}

0 commit comments

Comments
 (0)