Skip to content

Commit fba3c78

Browse files
feat(operations): Introduce RegisterOperationRelaxed (#637)
- updated docs for AsUntyped on when to use AsUntypedRelaxed - introduced a new helper function `RegisterOperationRelaxed` for registring operations with relaxed type checking
1 parent d969a5b commit fba3c78

File tree

4 files changed

+217
-0
lines changed

4 files changed

+217
-0
lines changed

.changeset/witty-banks-fetch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
feat(operations): introduce RegisterOperationRelaxed

operations/operation.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func (o *Operation[IN, OUT, DEP]) execute(b Bundle, deps DEP, input IN) (output
104104
// AsUntyped converts the operation to an untyped operation.
105105
// This is useful for storing operations in a slice or passing them around without type constraints.
106106
// Warning: The input and output types will be converted to `any`, so type safety is lost.
107+
// Use AsUntypedRelaxed if the input is from YAML unmarshaling and result in map[string]any.
107108
func (o *Operation[IN, OUT, DEP]) AsUntyped() *Operation[any, any, any] {
108109
return &Operation[any, any, any]{
109110
def: o.def,

operations/registry.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ func RegisterOperation[D, I, O any](r *OperationRegistry, op ...*Operation[D, I,
5151
}
5252
}
5353

54+
// RegisterOperationRelaxed registers new operations in the registry with relaxed input type checking.
55+
// This is useful when inputs come from YAML unmarshaling and result in map[string]any.
56+
// It uses JSON marshaling/unmarshaling to convert compatible types when direct type assertion fails.
57+
// Warning: The input and output types will be converted to `any`, so type safety is lost.
58+
// If the same operation is registered multiple times, it will overwrite the previous one.
59+
func RegisterOperationRelaxed[D, I, O any](r *OperationRegistry, op ...*Operation[D, I, O]) {
60+
for _, o := range op {
61+
key := generateRegistryKey(o.Def())
62+
r.ops[key] = o.AsUntypedRelaxed()
63+
}
64+
}
65+
5466
// generateRegistryKey creates a unique key for the operation registry based on the operation's ID and version.
5567
// This key is used to store and retrieve operations in the registry.
5668
func generateRegistryKey(def Definition) string {

operations/registry_test.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,202 @@ func TestOperationRegistry_Retrieve(t *testing.T) {
160160
})
161161
}
162162
}
163+
164+
func TestRegisterOperation(t *testing.T) {
165+
t.Parallel()
166+
167+
op1 := NewOperation(
168+
"test-op-1",
169+
semver.MustParse("1.0.0"),
170+
"Operation 1",
171+
func(e Bundle, deps OpDeps, input string) (string, error) { return input, nil },
172+
)
173+
op2 := NewOperation(
174+
"test-op-2",
175+
semver.MustParse("2.0.0"),
176+
"Operation 2",
177+
func(e Bundle, deps OpDeps, input int) (int, error) { return input * 2, nil },
178+
)
179+
180+
t.Run("register single operation", func(t *testing.T) {
181+
t.Parallel()
182+
183+
registry := NewOperationRegistry()
184+
RegisterOperation(registry, op1)
185+
186+
retrievedOp, err := registry.Retrieve(op1.Def())
187+
require.NoError(t, err)
188+
assert.Equal(t, "test-op-1", retrievedOp.ID())
189+
assert.Equal(t, "1.0.0", retrievedOp.Version())
190+
})
191+
192+
t.Run("register multiple operations with different types", func(t *testing.T) {
193+
t.Parallel()
194+
195+
registry := NewOperationRegistry()
196+
// Register operations separately since they have different type parameters
197+
RegisterOperation(registry, op1)
198+
RegisterOperation(registry, op2)
199+
200+
retrievedOp1, err := registry.Retrieve(op1.Def())
201+
require.NoError(t, err)
202+
assert.Equal(t, "test-op-1", retrievedOp1.ID())
203+
204+
retrievedOp2, err := registry.Retrieve(op2.Def())
205+
require.NoError(t, err)
206+
assert.Equal(t, "test-op-2", retrievedOp2.ID())
207+
})
208+
209+
t.Run("overwrite existing operation", func(t *testing.T) {
210+
t.Parallel()
211+
212+
op1Updated := NewOperation(
213+
"test-op-1",
214+
semver.MustParse("1.0.0"),
215+
"Operation 1 Updated",
216+
func(e Bundle, deps OpDeps, input string) (string, error) { return input + "-updated", nil },
217+
)
218+
219+
registry := NewOperationRegistry()
220+
RegisterOperation(registry, op1)
221+
RegisterOperation(registry, op1Updated)
222+
223+
retrievedOp, err := registry.Retrieve(op1.Def())
224+
require.NoError(t, err)
225+
assert.Equal(t, "Operation 1 Updated", retrievedOp.Description())
226+
})
227+
}
228+
229+
func TestRegisterOperationRelaxed(t *testing.T) {
230+
t.Parallel()
231+
232+
type TestInput struct {
233+
A int `json:"a"`
234+
B int `json:"b"`
235+
}
236+
237+
op1 := NewOperation(
238+
"sum-op",
239+
semver.MustParse("1.0.0"),
240+
"Sum operation with struct input",
241+
func(e Bundle, deps OpDeps, input TestInput) (int, error) {
242+
return input.A + input.B, nil
243+
},
244+
)
245+
246+
op2 := NewOperation(
247+
"multiply-op",
248+
semver.MustParse("1.0.0"),
249+
"Multiply operation",
250+
func(e Bundle, deps OpDeps, input int) (int, error) {
251+
return input * 2, nil
252+
},
253+
)
254+
255+
t.Run("register and execute with map input from YAML", func(t *testing.T) {
256+
t.Parallel()
257+
258+
registry := NewOperationRegistry()
259+
RegisterOperationRelaxed(registry, op1)
260+
261+
retrievedOp, err := registry.Retrieve(op1.Def())
262+
require.NoError(t, err)
263+
264+
// Simulate input from YAML unmarshaling
265+
yamlInput := map[string]any{
266+
"a": 10,
267+
"b": 20,
268+
}
269+
270+
bundle := NewBundle(context.Background, logger.Nop(), nil)
271+
result, err := retrievedOp.handler(bundle, OpDeps{}, yamlInput)
272+
require.NoError(t, err)
273+
assert.Equal(t, 30, result)
274+
})
275+
276+
t.Run("register multiple operations with relaxed typing", func(t *testing.T) {
277+
t.Parallel()
278+
279+
registry := NewOperationRegistry()
280+
// Register operations separately since they have different type parameters
281+
RegisterOperationRelaxed(registry, op1)
282+
RegisterOperationRelaxed(registry, op2)
283+
284+
// Verify both operations are registered
285+
retrievedOp1, err := registry.Retrieve(op1.Def())
286+
require.NoError(t, err)
287+
assert.Equal(t, "sum-op", retrievedOp1.ID())
288+
289+
retrievedOp2, err := registry.Retrieve(op2.Def())
290+
require.NoError(t, err)
291+
assert.Equal(t, "multiply-op", retrievedOp2.ID())
292+
})
293+
294+
t.Run("overwrite operation with relaxed version", func(t *testing.T) {
295+
t.Parallel()
296+
297+
op1Strict := NewOperation(
298+
"sum-op",
299+
semver.MustParse("1.0.0"),
300+
"Sum operation strict",
301+
func(e Bundle, deps OpDeps, input TestInput) (int, error) {
302+
return input.A + input.B + 1, nil
303+
},
304+
)
305+
306+
registry := NewOperationRegistry()
307+
RegisterOperation(registry, op1Strict)
308+
RegisterOperationRelaxed(registry, op1) // Should overwrite
309+
310+
retrievedOp, err := registry.Retrieve(op1.Def())
311+
require.NoError(t, err)
312+
313+
// Use map input which would fail with strict version
314+
yamlInput := map[string]any{
315+
"a": 10,
316+
"b": 20,
317+
}
318+
319+
bundle := NewBundle(context.Background, logger.Nop(), nil)
320+
result, err := retrievedOp.handler(bundle, OpDeps{}, yamlInput)
321+
require.NoError(t, err)
322+
// If it was the strict version, result would be 31
323+
assert.Equal(t, 30, result)
324+
})
325+
326+
t.Run("handle type conversion errors gracefully", func(t *testing.T) {
327+
t.Parallel()
328+
329+
registry := NewOperationRegistry()
330+
RegisterOperationRelaxed(registry, op1)
331+
332+
retrievedOp, err := registry.Retrieve(op1.Def())
333+
require.NoError(t, err)
334+
335+
// Provide input that cannot be converted
336+
invalidInput := "not a struct"
337+
338+
bundle := NewBundle(context.Background, logger.Nop(), nil)
339+
_, err = retrievedOp.handler(bundle, OpDeps{}, invalidInput)
340+
require.Error(t, err)
341+
assert.Contains(t, err.Error(), "input type mismatch")
342+
})
343+
344+
t.Run("execute operation with direct struct input", func(t *testing.T) {
345+
t.Parallel()
346+
347+
registry := NewOperationRegistry()
348+
RegisterOperationRelaxed(registry, op1)
349+
350+
retrievedOp, err := registry.Retrieve(op1.Def())
351+
require.NoError(t, err)
352+
353+
// Even with relaxed typing, direct struct input should work
354+
directInput := TestInput{A: 5, B: 15}
355+
356+
bundle := NewBundle(context.Background, logger.Nop(), nil)
357+
result, err := retrievedOp.handler(bundle, OpDeps{}, directInput)
358+
require.NoError(t, err)
359+
assert.Equal(t, 20, result)
360+
})
361+
}

0 commit comments

Comments
 (0)