@reventlessdev/reventless-core 3.0.0-alpha.259 → 3.0.0-alpha.261
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/package.json +11 -11
- package/src/Behavior.res +1 -4
- package/src/Message.res +17 -14
- package/src/Projection.res +51 -25
- package/src/RequestContext.res +8 -9
- package/src/ResourceAttribution.res +28 -14
- package/src/adapter/Adapter.res +48 -4
- package/src/adapter/EventLogProvisioning/EventLogProvisioning.res +5 -5
- package/src/adapter/Messaging/Messaging_Log_Backend.res +10 -7
- package/src/adapter/Messaging/Messaging_Log_Backend.res.mjs +2 -4
- package/src/adapter/Monitoring/Monitoring.res +16 -17
- package/src/adapter/QueryInterception/QueryInterception.res +0 -1
- package/src/adapter/Runtime/AggregateRuntime_Builder_Common.res +12 -5
- package/src/adapter/Runtime/EventCollectorRuntime_Builder_Single.res +11 -7
- package/src/adapter/Runtime/PluginRuntime_Builder_Micro.res +1 -7
- package/src/adapter/Runtime/PluginRuntime_Builder_Single.res +1 -7
- package/src/adapter/RuntimeExtension/RuntimeExtension.res +0 -1
- package/src/admin/Platform_Admin.res +17 -6
- package/src/admin/Platform_AdminApi.res +14 -9
- package/src/admin/Platform_Admin_Callback.res +1 -1
- package/src/admin/Platform_BakedManifest.res +34 -34
- package/src/admin/Platform_ComponentDefinitionsApi.res +9 -21
- package/src/admin/Platform_PluginStructuresApi.res +15 -12
- package/src/admin/UiFragmentRegistry/StateChange/UiFragmentRegistry.res +1 -1
- package/src/components/Aggregate/Aggregate.res +13 -12
- package/src/components/Aggregate/Aggregate_Builder.res +22 -21
- package/src/components/Aggregate/Aggregate_Callback.res +91 -86
- package/src/components/Api/ApiNoApiHelpers.res +1 -3
- package/src/components/Api/Api_Ids.res +0 -1
- package/src/components/Api/Api_Naming.res +41 -18
- package/src/components/Api/GraphQL_FragmentGenerator.res +42 -52
- package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +11 -4
- package/src/components/Api/GraphQL_SchemaInspector.res +5 -3
- package/src/components/Api/GraphQL_Stitcher.res +9 -10
- package/src/components/Api/MCP_SchemaGenerator.res +8 -12
- package/src/components/Api/OwnerScopeDiagnostics.res +1 -2
- package/src/components/Api/QueryDbListQuery.res +13 -6
- package/src/components/Api/SchemaType.res +40 -20
- package/src/components/Api/SchemaType.res.mjs +16 -0
- package/src/components/Api/SuryToJsonSchema.res +18 -10
- package/src/components/Api/SuryToJsonSchema.res.mjs +135 -121
- package/src/components/AutomationSlice/AutomationSlice.res +4 -4
- package/src/components/AutomationSlice/AutomationSlice_Builder.res +15 -14
- package/src/components/AutomationSlice/AutomationSlice_Callback.res +3 -3
- package/src/components/Builder_Helpers.res +12 -23
- package/src/components/CommandGenerator/CommandGenerator.res +6 -1
- package/src/components/CommandGenerator/CommandGenerator_Builder.res +29 -21
- package/src/components/CommandGenerator/CommandGenerator_Callback.res +17 -21
- package/src/components/CommandTopic/CommandTopic.res +3 -3
- package/src/components/CommandTopic/CommandTopic_Builder.res +10 -10
- package/src/components/CommandTopic/CommandTopic_Callback.res +1 -4
- package/src/components/CommandTopic/CommandTopic_Helpers.res +1 -2
- package/src/components/Counter/Counter_Builder.res +1 -6
- package/src/components/Counter/Counter_Callback.res +3 -3
- package/src/components/Dcb/Dcb_Builder.res +970 -956
- package/src/components/DcbEventLog/DcbEventLog.res +4 -4
- package/src/components/DcbEventLog/DcbEventLog_Builder.res +0 -1
- package/src/components/DcbEventLog/DcbEventLog_Operations.res +17 -18
- package/src/components/EventCollector/EventCollector_Builder.res +2 -1
- package/src/components/EventLog/EventLog.res +5 -1
- package/src/components/EventLog/EventLog_Operations.res +12 -8
- package/src/components/EventMapper/EventMapper.res +8 -10
- package/src/components/EventMapper/EventMapper_Builder.res +4 -1
- package/src/components/EventMapper/EventMapper_Callback.res +46 -31
- package/src/components/EventTopic/EventTopic.res +3 -3
- package/src/components/EventTopic/EventTopic_Builder.res +1 -1
- package/src/components/Extension/Extension.res +4 -1
- package/src/components/Extension/Extension_Operations.res +11 -3
- package/src/components/ExtensionPoint/ExtensionPoint.res +3 -3
- package/src/components/ExtensionPoint/ExtensionPoint_Builder.res +9 -10
- package/src/components/ExtensionPoint/ExtensionPoint_Callback.res +2 -6
- package/src/components/ExtensionPoint/ExtensionPoint_Operations.res +13 -5
- package/src/components/Heartbeat/Heartbeat_Callback.res +3 -3
- package/src/components/InboundTranslationSlice/InboundTranslationSlice.res +4 -4
- package/src/components/InboundTranslationSlice/InboundTranslationSlice_Builder.res +7 -10
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice.res +4 -4
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Builder.res +15 -14
- package/src/components/QueryDb/QueryDb_Adapter.res +1 -3
- package/src/components/QueryDb/QueryDb_Operations.res +10 -10
- package/src/components/ReadModel/ReadModel.res +3 -3
- package/src/components/ReadModel/ReadModel_Builder.res +1 -2
- package/src/components/ReadModel/ReadModel_Callback.res +2 -1
- package/src/components/SideEffectHandler/SideEffectHandler_Callback.res +5 -2
- package/src/components/StateChangeSlice/StateChangeSlice.res +3 -3
- package/src/components/StateChangeSlice/StateChangeSlice_Builder.res +23 -4
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +22 -6
- package/src/components/StateTopic.res +1 -3
- package/src/components/StateViewSlice/StateViewSlice.res +4 -4
- package/src/components/StateViewSlice/StateViewSlice_Builder.res +79 -68
- package/src/components/StateViewSlice/StateViewSlice_Callback.res +3 -1
- package/src/components/Task/Task.res +3 -3
- package/src/components/Task/Task_Adapter.res +4 -1
- package/src/components/Task/Task_Builder.res +24 -21
- package/src/plugin/api/PluginBaseFragment.res +9 -3
- package/src/plugin/component/Capability_Inference.res +8 -10
- package/src/plugin/component/Plugin.res +6 -6
- package/src/plugin/component/Plugin_Builder.res +54 -50
- package/src/plugin/component/Plugin_Callback.res +10 -6
- package/src/plugin/component/Plugin_Helpers.res +264 -230
- package/src/plugin/component/Plugin_Structure.res +370 -324
- package/src/plugin/component/Plugin_SubscriptionSchema.res +8 -6
- package/src/plugin/component/SchemaWalker.res +11 -12
- package/src/plugin/connect/PluginConnectExtension_Mapping.res +45 -50
- package/src/plugin/lifecycle/PluginBehavior.res +3 -4
- package/src/plugin/lifecycle/PluginSpec.res +1 -2
- package/src/plugin/lifecycle/PluginsProjection.res +16 -5
- package/src/plugin/lifecycle/PluginsReadModelSpec.res +0 -2
- package/src/util/CommandPublisher.res +9 -2
- package/src/util/CsvStream.res +3 -3
- package/src/util/Interstack.res +7 -15
- package/src/util/Interstack.resi +3 -3
- package/src/util/LogFormat.res +5 -9
- package/src/util/Logger.res +1 -2
- package/src/util/Lru.res +2 -1
- package/src/util/Lru.resi +2 -3
- package/src/util/ScheduleOps.res +24 -27
- package/src/util/StorePrefixCollision.res +6 -9
- package/src/util/Util_Promise.res +4 -1
- package/src/util/Util_Promise.res.mjs +1 -1
- package/tests/IdentityTest.res +166 -113
- package/tests/ResourceAttributionTest.res +28 -25
- package/tests/StorePrefixCollisionTest.res +2 -1
- package/tests/TaskBucketNamingTest.res +3 -2
- package/tests/adapter/EventLogProvisioningTest.res +51 -48
- package/tests/adapter/MonitoringTest.res +81 -38
- package/tests/adapter/QueryInterceptionTest.res +2 -2
- package/tests/adapter/RefusalVocabularyTest.res +31 -23
- package/tests/adapter/RuntimeExtensionHookReachTest.res +9 -8
- package/tests/adapter/RuntimeExtensionTest.res +5 -3
- package/tests/admin/AdminApiSchemaDriftTest.res +22 -16
- package/tests/admin/Platform_Admin_StructureTest.res +10 -11
- package/tests/admin/Platform_BakedManifestTest.res +31 -35
- package/tests/admin/Platform_ComponentDefinitionsApiTest.res +18 -15
- package/tests/admin/Platform_PluginStructuresApiTest.res +5 -3
- package/tests/admin/Platform_UIFragmentsApiTest.res +6 -3
- package/tests/aggregate/AggregateCacheTest.res +10 -12
- package/tests/aggregate/AggregateCallbackTest.res +75 -46
- package/tests/aggregate/AggregateCausationTest.res +19 -20
- package/tests/aggregate/AggregateConflictTest.res +41 -19
- package/tests/aggregate/AggregateFixtures.res +15 -5
- package/tests/aggregate/AggregateRejectionTest.res +97 -80
- package/tests/aggregate/AggregateSnapshotTest.res +44 -16
- package/tests/api/Api_IdsTest.res +10 -4
- package/tests/api/BrandedMarkerCompositionTest.res +48 -36
- package/tests/api/GeolocationSemanticTest.res +3 -3
- package/tests/api/GraphQL_FragmentGeneratorTest.res +66 -54
- package/tests/api/OwnerScopeDiagnosticsTest.res +31 -27
- package/tests/api/StorageRefFieldsTest.res +30 -18
- package/tests/api/SuryToJsonSchemaTest.res +1338 -981
- package/tests/api/SuryToJsonSchemaTest.res.mjs +31 -0
- package/tests/api/TaggedUnionSchemaTest.res +17 -24
- package/tests/commandgenerator/CommandGeneratorCallbackTest.res +235 -179
- package/tests/commandgenerator/CommandGeneratorFixtures.res +8 -13
- package/tests/commandgenerator/OwnerStampingTest.res +91 -76
- package/tests/commandtopic/CommandOutcomeHookTest.res +198 -155
- package/tests/commandtopic/CommandTopicCallbackFixtures.res +5 -4
- package/tests/commandtopic/CommandTopicCallbackTest.res +61 -43
- package/tests/commandtopic/CommandTopicHelpersRejectionTest.res +70 -55
- package/tests/counter/CounterCallbackTest.res +40 -34
- package/tests/counter/CounterFixtures.res +4 -4
- package/tests/dcb/DcbDecodeTest.res +115 -84
- package/tests/dcb/DcbEventLogOperationsTest.res +194 -143
- package/tests/dcb/DcbEventLogSchemaTest.res +13 -13
- package/tests/dcb/DcbFixtures.res +4 -5
- package/tests/dcb/DcbScopeInferenceTest.res +173 -114
- package/tests/dcb/DcbStateChangeSliceTest.res +391 -262
- package/tests/dcb/DcbTagTest.res +22 -11
- package/tests/dcb/DcbValidationTest.res +288 -218
- package/tests/eventlog/EventLogFixtures.res +4 -4
- package/tests/eventlog/EventLogOperationsTest.res +102 -67
- package/tests/eventlog/EventLogRetryTest.res +104 -65
- package/tests/eventmapper/EventMapperCallbackTest.res +118 -83
- package/tests/eventmapper/EventMapperFixtures.res +15 -23
- package/tests/eventmapper/EventMapperTest.res +54 -38
- package/tests/eventtopic/EventTopicFixtures.res +3 -4
- package/tests/eventtopic/EventTopicOperationsTest.res +41 -29
- package/tests/extensionpoint/ExtensionPointCallbackTest.res +58 -45
- package/tests/extensionpoint/ExtensionPointFixtures.res +16 -20
- package/tests/extensionpoint/ExtensionPointOperationsTest.res +70 -49
- package/tests/extensionpoint/PluginDisconnectScheduleTest.res +2 -1
- package/tests/logger/LogFormatTest.res +104 -82
- package/tests/logger/LoggerLazyTest.res +21 -12
- package/tests/message/MessageTest.res +96 -93
- package/tests/message/MetaEnvelopeTest.res +26 -30
- package/tests/plugin/HeartbeatDisconnectGraceTest.res +31 -25
- package/tests/plugin/ManifestVisibilityTest.res +20 -12
- package/tests/plugin/PluginDefinitionScalars.res +1 -2
- package/tests/plugin/PluginLifecycleCorpusTest.res +15 -12
- package/tests/plugin/PluginSpecExperiment.res +0 -1
- package/tests/plugin/PluginStructureTest.res +1533 -1030
- package/tests/plugin/PluginStructureTest.res.mjs +6 -0
- package/tests/plugin/Plugin_EventQuerySchemaTest.res +21 -10
- package/tests/plugin/SchemaWalkerTest.res +116 -70
- package/tests/plugin/StateChange/PsReserveStock.res +4 -4
- package/tests/plugin/StateView/PsAnnotatedView.res +6 -4
- package/tests/plugin/StateView/PsAnnotatedView.res.mjs +7 -3
- package/tests/plugin/StateView/PsCustomersView.res +4 -4
- package/tests/plugin/StateView/PsShipmentsView.res +3 -2
- package/tests/projection/LifecycleTrailTest.res +1 -4
- package/tests/projection/ProjectionOptimizeTest.res +13 -9
- package/tests/querydb/QueryDbFixtures.res +0 -1
- package/tests/querydb/QueryDbOperationsTest.res +99 -64
- package/tests/querydb/TaggedUnionStampTest.res +10 -8
- package/tests/sideeffecthandler/SideEffectHandlerCallbackTest.res +50 -35
- package/tests/sideeffecthandler/SideEffectHandlerFixtures.res +4 -10
- package/tests/util/CommandPublisherTest.res +0 -1
- package/tests/util/CsvStreamTest.res +3 -3
- package/tests/util/LruTest.res +84 -56
|
@@ -40,70 +40,82 @@ let emptySpec: Reventless.StateAnnotations.stateAnnotationSpec = {
|
|
|
40
40
|
|
|
41
41
|
describe("SuryToJsonSchema:", () => {
|
|
42
42
|
describe("deriveObjectSchema with no annotations:", () => {
|
|
43
|
-
testSync(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
->
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
43
|
+
testSync(
|
|
44
|
+
"emits plain JSON Schema when no metadata is attached",
|
|
45
|
+
() => {
|
|
46
|
+
let schema = S.schema(
|
|
47
|
+
s =>
|
|
48
|
+
{
|
|
49
|
+
"id": s.matches(S.string),
|
|
50
|
+
"name": s.matches(S.string),
|
|
51
|
+
},
|
|
52
|
+
)->S.castToUnknown
|
|
53
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
54
|
+
let idSchema = getPropertyOf(json, "id")
|
|
55
|
+
expect(idSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")))->toBe(None)
|
|
56
|
+
},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
testSync(
|
|
60
|
+
"emits format:\"date-time\" for a DateTime-marked string field",
|
|
61
|
+
() => {
|
|
62
|
+
let schema = S.schema(
|
|
63
|
+
s =>
|
|
64
|
+
{
|
|
65
|
+
"placedAt": s.matches(Reventless.DateTime.schema),
|
|
66
|
+
"name": s.matches(S.string),
|
|
67
|
+
},
|
|
68
|
+
)->S.castToUnknown
|
|
69
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
70
|
+
expect(
|
|
71
|
+
getPropertyOf(json, "placedAt")
|
|
72
|
+
->Option.flatMap(s => getProperty(s, "format"))
|
|
73
|
+
->Option.flatMap(JSON.Decode.string),
|
|
74
|
+
)->toBe(Some("date-time"))
|
|
75
|
+
// A plain string field stays plain — the marker is opt-in.
|
|
76
|
+
expect(getPropertyOf(json, "name")->Option.flatMap(s => getProperty(s, "format")))->toBe(
|
|
77
|
+
None,
|
|
78
|
+
)
|
|
79
|
+
},
|
|
80
|
+
)
|
|
75
81
|
|
|
76
82
|
// The no-drift claim behind writing `placedAt: Reventless.DateTime.t` in
|
|
77
83
|
// place of `@s.matches(DateTime.string) string`: the type emits exactly what
|
|
78
84
|
// the annotation emitted, semantic key still absent. This is the one test
|
|
79
85
|
// that would catch that output changing by accident.
|
|
80
|
-
testSync(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
testSync(
|
|
87
|
+
"a DateTime field emits format and no semantic key",
|
|
88
|
+
() => {
|
|
89
|
+
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
90
|
+
S.schema(s => {"placedAt": s.matches(Reventless.DateTime.schema)})->S.castToUnknown,
|
|
91
|
+
)
|
|
92
|
+
let keyOf = key => getPropertyOf(json, "placedAt")->Option.flatMap(s => getProperty(s, key))
|
|
93
|
+
expect((keyOf("type"), keyOf("format"), keyOf("x-reventless-semantic")))->toEqual((
|
|
94
|
+
Some(JSON.Encode.string("string")),
|
|
95
|
+
Some(JSON.Encode.string("date-time")),
|
|
96
|
+
None,
|
|
97
|
+
))
|
|
98
|
+
},
|
|
99
|
+
)
|
|
91
100
|
|
|
92
101
|
// A calendar date speaks both vocabularies, and that asymmetry with
|
|
93
102
|
// `DateTime` is deliberate: `DateTime`'s bare-`format` output predates the
|
|
94
103
|
// generic marker and is a published contract, so it keeps it; a semantic
|
|
95
104
|
// arriving now has no such history and should say what it is as well.
|
|
96
|
-
testSync(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
testSync(
|
|
106
|
+
"a CalendarDate field emits format:\"date\" AND the semantic key",
|
|
107
|
+
() => {
|
|
108
|
+
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
109
|
+
S.schema(s => {"issuedOn": s.matches(Reventless.CalendarDate.schema)})->S.castToUnknown,
|
|
110
|
+
)
|
|
111
|
+
let keyOf = key => getPropertyOf(json, "issuedOn")->Option.flatMap(s => getProperty(s, key))
|
|
112
|
+
expect((keyOf("type"), keyOf("format"), keyOf("x-reventless-semantic")))->toEqual((
|
|
113
|
+
Some(JSON.Encode.string("string")),
|
|
114
|
+
Some(JSON.Encode.string("date")),
|
|
115
|
+
Some(JSON.Encode.string("date")),
|
|
116
|
+
))
|
|
117
|
+
},
|
|
118
|
+
)
|
|
107
119
|
})
|
|
108
120
|
|
|
109
121
|
describe("deriveObjectSchema with an owner field:", () => {
|
|
@@ -111,44 +123,53 @@ describe("SuryToJsonSchema:", () => {
|
|
|
111
123
|
getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-owner"))
|
|
112
124
|
|
|
113
125
|
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
114
|
-
S.schema(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
S.schema(
|
|
127
|
+
s =>
|
|
128
|
+
{
|
|
129
|
+
"customerId": s.matches(Reventless.Owner.string),
|
|
130
|
+
"note": s.matches(S.string),
|
|
131
|
+
},
|
|
119
132
|
)->S.castToUnknown,
|
|
120
133
|
)
|
|
121
134
|
|
|
122
|
-
testSync(
|
|
123
|
-
|
|
135
|
+
testSync(
|
|
136
|
+
"the marked field carries x-reventless-owner",
|
|
137
|
+
() => expect(ownerOf(json, "customerId"))->toEqual(Some(JSON.Encode.bool(true))),
|
|
124
138
|
)
|
|
125
139
|
|
|
126
140
|
// The control: ownership is opt-in, and an unmarked string must stay one.
|
|
127
141
|
// Without this, a bug that marked every field would still pass above.
|
|
128
|
-
testSync(
|
|
129
|
-
|
|
142
|
+
testSync(
|
|
143
|
+
"an unmarked string field carries nothing",
|
|
144
|
+
() => expect(ownerOf(json, "note"))->toBe(None),
|
|
130
145
|
)
|
|
131
146
|
|
|
132
|
-
testSync(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
testSync(
|
|
148
|
+
"the field is still a plain string on the wire",
|
|
149
|
+
() =>
|
|
150
|
+
expect(
|
|
151
|
+
getPropertyOf(json, "customerId")->Option.flatMap(s => getProperty(s, "type")),
|
|
152
|
+
)->toEqual(Some(JSON.Encode.string("string"))),
|
|
136
153
|
)
|
|
137
154
|
|
|
138
155
|
// An optional field keeps its marker inside the union wrapper. A reader that
|
|
139
156
|
// only inspects the outer schema answers "not the owner" here — which for a
|
|
140
157
|
// predicate that scopes reads means an unscoped view, silently. This is the
|
|
141
158
|
// case `Owner.isFieldOwner` exists for.
|
|
142
|
-
testSync(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
159
|
+
testSync(
|
|
160
|
+
"an optional owner field is still recognised",
|
|
161
|
+
() => {
|
|
162
|
+
let optJson = SuryToJsonSchema.deriveObjectSchema(
|
|
163
|
+
S.schema(
|
|
164
|
+
s =>
|
|
165
|
+
{
|
|
166
|
+
"customerId": s.matches(S.option(Reventless.Owner.string)),
|
|
167
|
+
},
|
|
168
|
+
)->S.castToUnknown,
|
|
169
|
+
)
|
|
170
|
+
expect(ownerOf(optJson, "customerId"))->toEqual(Some(JSON.Encode.bool(true)))
|
|
171
|
+
},
|
|
172
|
+
)
|
|
152
173
|
})
|
|
153
174
|
|
|
154
175
|
describe("deriveObjectSchema with a sensitive field:", () => {
|
|
@@ -158,449 +179,557 @@ describe("SuryToJsonSchema:", () => {
|
|
|
158
179
|
getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-owner"))
|
|
159
180
|
|
|
160
181
|
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
161
|
-
S.schema(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
182
|
+
S.schema(
|
|
183
|
+
s =>
|
|
184
|
+
{
|
|
185
|
+
"resetToken": s.matches(Reventless.Sensitive.string),
|
|
186
|
+
"note": s.matches(S.string),
|
|
187
|
+
"contact": s.matches(Reventless.Email.schema),
|
|
188
|
+
},
|
|
167
189
|
)->S.castToUnknown,
|
|
168
190
|
)
|
|
169
191
|
|
|
170
|
-
testSync(
|
|
171
|
-
|
|
192
|
+
testSync(
|
|
193
|
+
"the marked field carries x-reventless-sensitive",
|
|
194
|
+
() => expect(sensitiveOf(json, "resetToken"))->toEqual(Some(JSON.Encode.bool(true))),
|
|
172
195
|
)
|
|
173
196
|
|
|
174
197
|
// The control. Absent means "not stated", and a reader that misses the
|
|
175
198
|
// marker renders the value — so a bug that marked everything would look like
|
|
176
199
|
// caution while a bug that marked nothing leaks. Both need catching, and
|
|
177
200
|
// only this assertion catches the first.
|
|
178
|
-
testSync(
|
|
179
|
-
|
|
201
|
+
testSync(
|
|
202
|
+
"an unmarked string field carries nothing",
|
|
203
|
+
() => expect(sensitiveOf(json, "note"))->toBe(None),
|
|
180
204
|
)
|
|
181
205
|
|
|
182
206
|
// A contact detail is sensitive whether or not anybody wrote it down: the
|
|
183
207
|
// whole meaning of the semantic is "how to reach a particular person".
|
|
184
|
-
testSync(
|
|
185
|
-
|
|
208
|
+
testSync(
|
|
209
|
+
"an email field is sensitive with no annotation",
|
|
210
|
+
() => expect(sensitiveOf(json, "contact"))->toEqual(Some(JSON.Encode.bool(true))),
|
|
186
211
|
)
|
|
187
212
|
|
|
188
|
-
testSync(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
213
|
+
testSync(
|
|
214
|
+
"the field is still a plain string on the wire",
|
|
215
|
+
() =>
|
|
216
|
+
expect(
|
|
217
|
+
getPropertyOf(json, "resetToken")->Option.flatMap(s => getProperty(s, "type")),
|
|
218
|
+
)->toEqual(Some(JSON.Encode.string("string"))),
|
|
192
219
|
)
|
|
193
220
|
|
|
194
221
|
// Same trap as the owner case, and worse in consequence: a reader that only
|
|
195
222
|
// inspects the outer schema answers "not sensitive" for an optional field,
|
|
196
223
|
// and the value goes into a message.
|
|
197
|
-
testSync(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
224
|
+
testSync(
|
|
225
|
+
"an optional sensitive field is still recognised",
|
|
226
|
+
() => {
|
|
227
|
+
let optJson = SuryToJsonSchema.deriveObjectSchema(
|
|
228
|
+
S.schema(
|
|
229
|
+
s =>
|
|
230
|
+
{
|
|
231
|
+
"resetToken": s.matches(S.option(Reventless.Sensitive.string)),
|
|
232
|
+
},
|
|
233
|
+
)->S.castToUnknown,
|
|
234
|
+
)
|
|
235
|
+
expect(sensitiveOf(optJson, "resetToken"))->toEqual(Some(JSON.Encode.bool(true)))
|
|
236
|
+
},
|
|
237
|
+
)
|
|
207
238
|
|
|
208
239
|
// The composition rule. `mark` wraps rather than replaces, so a field that
|
|
209
240
|
// is both an owner and sensitive keeps both — a marker that silently
|
|
210
241
|
// replaced the other would unscope a view or unmask a value.
|
|
211
|
-
testSync(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
242
|
+
testSync(
|
|
243
|
+
"sensitivity composes with ownership on one field",
|
|
244
|
+
() => {
|
|
245
|
+
let bothJson = SuryToJsonSchema.deriveObjectSchema(
|
|
246
|
+
S.schema(
|
|
247
|
+
s =>
|
|
248
|
+
{
|
|
249
|
+
"customerId": s.matches(Reventless.Sensitive.mark(Reventless.Owner.string)),
|
|
250
|
+
},
|
|
251
|
+
)->S.castToUnknown,
|
|
252
|
+
)
|
|
253
|
+
expect((sensitiveOf(bothJson, "customerId"), ownerOf(bothJson, "customerId")))->toEqual((
|
|
254
|
+
Some(JSON.Encode.bool(true)),
|
|
255
|
+
Some(JSON.Encode.bool(true)),
|
|
256
|
+
))
|
|
257
|
+
},
|
|
258
|
+
)
|
|
224
259
|
})
|
|
225
260
|
|
|
226
261
|
describe("deriveObjectSchema with stateAnnotations metadata:", () => {
|
|
227
262
|
let withSpec = (schema, spec) =>
|
|
228
263
|
schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
|
|
229
264
|
|
|
230
|
-
testSync(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
{
|
|
312
|
-
|
|
313
|
-
"
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
->
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
265
|
+
testSync(
|
|
266
|
+
"emits x-reventless-id on field listed in ids",
|
|
267
|
+
() => {
|
|
268
|
+
let schema = S.schema(
|
|
269
|
+
s =>
|
|
270
|
+
{
|
|
271
|
+
"entityId": s.matches(S.string),
|
|
272
|
+
"name": s.matches(S.string),
|
|
273
|
+
},
|
|
274
|
+
)->S.castToUnknown
|
|
275
|
+
let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
|
|
276
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
277
|
+
let entityIdSchema = getPropertyOf(json, "entityId")
|
|
278
|
+
expect(
|
|
279
|
+
entityIdSchema
|
|
280
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-id"))
|
|
281
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
282
|
+
)->toBe(Some(true))
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
testSync(
|
|
287
|
+
"emits x-reventless-compositeId on each compositeId field",
|
|
288
|
+
() => {
|
|
289
|
+
let schema = S.schema(
|
|
290
|
+
s =>
|
|
291
|
+
{
|
|
292
|
+
"environment": s.matches(S.string),
|
|
293
|
+
"platformName": s.matches(S.string),
|
|
294
|
+
"name": s.matches(S.string),
|
|
295
|
+
},
|
|
296
|
+
)->S.castToUnknown
|
|
297
|
+
let schema' = schema->withSpec({
|
|
298
|
+
...emptySpec,
|
|
299
|
+
compositeIds: ["environment", "platformName"],
|
|
300
|
+
})
|
|
301
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
302
|
+
let envSchema = getPropertyOf(json, "environment")
|
|
303
|
+
let platformSchema = getPropertyOf(json, "platformName")
|
|
304
|
+
expect((
|
|
305
|
+
envSchema
|
|
306
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-compositeId"))
|
|
307
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
308
|
+
platformSchema
|
|
309
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-compositeId"))
|
|
310
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
311
|
+
))->toEqual((Some(true), Some(true)))
|
|
312
|
+
},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
testSync(
|
|
316
|
+
"emits x-reventless-subId on field listed in subIds",
|
|
317
|
+
() => {
|
|
318
|
+
let schema = S.schema(
|
|
319
|
+
s =>
|
|
320
|
+
{
|
|
321
|
+
"itemId": s.matches(S.string),
|
|
322
|
+
"version": s.matches(S.string),
|
|
323
|
+
},
|
|
324
|
+
)->S.castToUnknown
|
|
325
|
+
let schema' = schema->withSpec({...emptySpec, subIds: ["version"]})
|
|
326
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
327
|
+
let versionSchema = getPropertyOf(json, "version")
|
|
328
|
+
expect(
|
|
329
|
+
versionSchema
|
|
330
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-subId"))
|
|
331
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
332
|
+
)->toBe(Some(true))
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
testSync(
|
|
337
|
+
"emits x-reventless-index as the index name when named",
|
|
338
|
+
() => {
|
|
339
|
+
let schema = S.schema(
|
|
340
|
+
s =>
|
|
341
|
+
{
|
|
342
|
+
"id": s.matches(S.string),
|
|
343
|
+
"ownerId": s.matches(S.string),
|
|
344
|
+
},
|
|
345
|
+
)->S.castToUnknown
|
|
346
|
+
let schema' = schema->withSpec({
|
|
347
|
+
...emptySpec,
|
|
348
|
+
indexes: [("ownerId", "byOwner")],
|
|
349
|
+
})
|
|
350
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
351
|
+
let ownerIdSchema = getPropertyOf(json, "ownerId")
|
|
352
|
+
expect(
|
|
353
|
+
ownerIdSchema
|
|
354
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-index"))
|
|
355
|
+
->Option.flatMap(JSON.Decode.string),
|
|
356
|
+
)->toBe(Some("byOwner"))
|
|
357
|
+
},
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
testSync(
|
|
361
|
+
"emits x-reventless-index as true for unnamed @index",
|
|
362
|
+
() => {
|
|
363
|
+
let schema = S.schema(
|
|
364
|
+
s =>
|
|
365
|
+
{
|
|
366
|
+
"id": s.matches(S.string),
|
|
367
|
+
"category": s.matches(S.string),
|
|
368
|
+
},
|
|
369
|
+
)->S.castToUnknown
|
|
370
|
+
let schema' = schema->withSpec({
|
|
371
|
+
...emptySpec,
|
|
372
|
+
indexes: [("category", "")],
|
|
373
|
+
})
|
|
374
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
375
|
+
let categorySchema = getPropertyOf(json, "category")
|
|
376
|
+
expect(
|
|
377
|
+
categorySchema
|
|
378
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-index"))
|
|
379
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
380
|
+
)->toBe(Some(true))
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
testSync(
|
|
385
|
+
"does not emit x-reventless-* on unannotated fields",
|
|
386
|
+
() => {
|
|
387
|
+
let schema = S.schema(
|
|
388
|
+
s =>
|
|
389
|
+
{
|
|
390
|
+
"entityId": s.matches(S.string),
|
|
391
|
+
"name": s.matches(S.string),
|
|
392
|
+
},
|
|
393
|
+
)->S.castToUnknown
|
|
394
|
+
let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
|
|
395
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
396
|
+
let nameSchema = getPropertyOf(json, "name")
|
|
397
|
+
expect(nameSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")))->toBe(None)
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
testSync(
|
|
402
|
+
"emits x-reventless-hidden on field listed in hidden",
|
|
403
|
+
() => {
|
|
404
|
+
let schema = S.schema(
|
|
405
|
+
s =>
|
|
406
|
+
{
|
|
407
|
+
"id": s.matches(S.string),
|
|
408
|
+
"deploymentId": s.matches(S.string),
|
|
409
|
+
},
|
|
410
|
+
)->S.castToUnknown
|
|
411
|
+
let schema' = schema->withSpec({...emptySpec, hidden: ["deploymentId"]})
|
|
412
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
413
|
+
let deploymentIdSchema = getPropertyOf(json, "deploymentId")
|
|
414
|
+
let idSchema = getPropertyOf(json, "id")
|
|
415
|
+
expect((
|
|
416
|
+
deploymentIdSchema
|
|
417
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-hidden"))
|
|
418
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
419
|
+
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-hidden")),
|
|
420
|
+
))->toEqual((Some(true), None))
|
|
421
|
+
},
|
|
422
|
+
)
|
|
362
423
|
|
|
363
424
|
// `@internal` drops the property rather than marking it. A marker is the
|
|
364
425
|
// right answer for a field that is on the surface and merely unwanted — that
|
|
365
426
|
// is `@hidden`, above. This one is not on the surface at all, so an entry
|
|
366
427
|
// here would describe a field the SDL does not declare, which is the
|
|
367
428
|
// mismatch that makes a generated list query name fields the server rejects.
|
|
368
|
-
testSync(
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
429
|
+
testSync(
|
|
430
|
+
"drops a field listed in internal rather than annotating it",
|
|
431
|
+
() => {
|
|
432
|
+
let schema = S.schema(
|
|
433
|
+
s =>
|
|
434
|
+
{
|
|
435
|
+
"id": s.matches(S.string),
|
|
436
|
+
"eventCollector": s.matches(S.string),
|
|
437
|
+
},
|
|
438
|
+
)->S.castToUnknown
|
|
439
|
+
let schema' = schema->withSpec({...emptySpec, internal: ["eventCollector"]})
|
|
440
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
441
|
+
expect((
|
|
442
|
+
getPropertyOf(json, "eventCollector")->Option.isNone,
|
|
443
|
+
getPropertyOf(json, "id")->Option.isSome,
|
|
444
|
+
))->toEqual((true, true))
|
|
445
|
+
},
|
|
446
|
+
)
|
|
382
447
|
|
|
383
448
|
// A dropped property must not be left behind in `required`, or a consumer
|
|
384
449
|
// building a form from the schema asks for a field it was never given.
|
|
385
|
-
testSync(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
->
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
)
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
->
|
|
420
|
-
->
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
"
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
->
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
"
|
|
472
|
-
"components"
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
->
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
"
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
)
|
|
578
|
-
|
|
579
|
-
|
|
450
|
+
testSync(
|
|
451
|
+
"keeps a dropped field out of required",
|
|
452
|
+
() => {
|
|
453
|
+
let schema = S.schema(
|
|
454
|
+
s =>
|
|
455
|
+
{
|
|
456
|
+
"id": s.matches(S.string),
|
|
457
|
+
"eventCollector": s.matches(S.string),
|
|
458
|
+
},
|
|
459
|
+
)->S.castToUnknown
|
|
460
|
+
let json =
|
|
461
|
+
schema
|
|
462
|
+
->withSpec({...emptySpec, internal: ["eventCollector"]})
|
|
463
|
+
->SuryToJsonSchema.deriveObjectSchema
|
|
464
|
+
expect(
|
|
465
|
+
json
|
|
466
|
+
->JSON.Decode.object
|
|
467
|
+
->Option.flatMap(o => o->Dict.get("required"))
|
|
468
|
+
->Option.flatMap(JSON.Decode.array)
|
|
469
|
+
->Option.getOr([])
|
|
470
|
+
->Array.filterMap(JSON.Decode.string),
|
|
471
|
+
)->toEqual(["id"])
|
|
472
|
+
},
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
testSync(
|
|
476
|
+
"emits x-reventless-summary on field listed in summary",
|
|
477
|
+
() => {
|
|
478
|
+
let schema = S.schema(
|
|
479
|
+
s =>
|
|
480
|
+
{
|
|
481
|
+
"id": s.matches(S.string),
|
|
482
|
+
"pluginName": s.matches(S.string),
|
|
483
|
+
},
|
|
484
|
+
)->S.castToUnknown
|
|
485
|
+
let schema' = schema->withSpec({...emptySpec, summary: ["pluginName"]})
|
|
486
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
487
|
+
let pluginNameSchema = getPropertyOf(json, "pluginName")
|
|
488
|
+
let idSchema = getPropertyOf(json, "id")
|
|
489
|
+
expect((
|
|
490
|
+
pluginNameSchema
|
|
491
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-summary"))
|
|
492
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
493
|
+
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-summary")),
|
|
494
|
+
))->toEqual((Some(true), None))
|
|
495
|
+
},
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
testSync(
|
|
499
|
+
"emits x-reventless-drillTarget as the slice name",
|
|
500
|
+
() => {
|
|
501
|
+
let schema = S.schema(
|
|
502
|
+
s =>
|
|
503
|
+
{
|
|
504
|
+
"id": s.matches(S.string),
|
|
505
|
+
"components": s.matches(S.string),
|
|
506
|
+
},
|
|
507
|
+
)->S.castToUnknown
|
|
508
|
+
let schema' = schema->withSpec({
|
|
509
|
+
...emptySpec,
|
|
510
|
+
drillTargets: [("components", "ResourceInventory")],
|
|
511
|
+
})
|
|
512
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
513
|
+
let componentsSchema = getPropertyOf(json, "components")
|
|
514
|
+
let idSchema = getPropertyOf(json, "id")
|
|
515
|
+
expect((
|
|
516
|
+
componentsSchema
|
|
517
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-drillTarget"))
|
|
518
|
+
->Option.flatMap(JSON.Decode.string),
|
|
519
|
+
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-drillTarget")),
|
|
520
|
+
))->toEqual((Some("ResourceInventory"), None))
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
testSync(
|
|
525
|
+
"emits x-reventless-drillTargetKey as the key path",
|
|
526
|
+
() => {
|
|
527
|
+
let schema = S.schema(
|
|
528
|
+
s =>
|
|
529
|
+
{
|
|
530
|
+
"id": s.matches(S.string),
|
|
531
|
+
"components": s.matches(S.string),
|
|
532
|
+
},
|
|
533
|
+
)->S.castToUnknown
|
|
534
|
+
let schema' = schema->withSpec({
|
|
535
|
+
...emptySpec,
|
|
536
|
+
drillTargets: [("components", "ResourceInventory")],
|
|
537
|
+
drillTargetKeys: [("components", "kind/name")],
|
|
538
|
+
})
|
|
539
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
540
|
+
let componentsSchema = getPropertyOf(json, "components")
|
|
541
|
+
expect(
|
|
542
|
+
componentsSchema
|
|
543
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-drillTargetKey"))
|
|
544
|
+
->Option.flatMap(JSON.Decode.string),
|
|
545
|
+
)->toBe(Some("kind/name"))
|
|
546
|
+
},
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
testSync(
|
|
550
|
+
"does not emit x-reventless-drillTargetKey when no key was supplied",
|
|
551
|
+
() => {
|
|
552
|
+
let schema = S.schema(
|
|
553
|
+
s =>
|
|
554
|
+
{
|
|
555
|
+
"id": s.matches(S.string),
|
|
556
|
+
"components": s.matches(S.string),
|
|
557
|
+
},
|
|
558
|
+
)->S.castToUnknown
|
|
559
|
+
let schema' = schema->withSpec({
|
|
560
|
+
...emptySpec,
|
|
561
|
+
drillTargets: [("components", "ResourceInventory")],
|
|
562
|
+
})
|
|
563
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
564
|
+
let componentsSchema = getPropertyOf(json, "components")
|
|
565
|
+
expect(
|
|
566
|
+
componentsSchema->Option.flatMap(s => getProperty(s, "x-reventless-drillTargetKey")),
|
|
567
|
+
)->toBe(None)
|
|
568
|
+
},
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
testSync(
|
|
572
|
+
"emits x-reventless-scan on field listed in scan",
|
|
573
|
+
() => {
|
|
574
|
+
let schema = S.schema(
|
|
575
|
+
s =>
|
|
576
|
+
{
|
|
577
|
+
"id": s.matches(S.string),
|
|
578
|
+
"status": s.matches(S.string),
|
|
579
|
+
},
|
|
580
|
+
)->S.castToUnknown
|
|
581
|
+
let schema' = schema->withSpec({...emptySpec, scan: ["status"]})
|
|
582
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
583
|
+
let statusSchema = getPropertyOf(json, "status")
|
|
584
|
+
let idSchema = getPropertyOf(json, "id")
|
|
585
|
+
expect((
|
|
586
|
+
statusSchema
|
|
587
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-scan"))
|
|
588
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
589
|
+
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-scan")),
|
|
590
|
+
))->toEqual((Some(true), None))
|
|
591
|
+
},
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
testSync(
|
|
595
|
+
"emits x-reventless-scanSort on field listed in scanSort",
|
|
596
|
+
() => {
|
|
597
|
+
let schema = S.schema(
|
|
598
|
+
s =>
|
|
599
|
+
{
|
|
600
|
+
"id": s.matches(S.string),
|
|
601
|
+
"name": s.matches(S.string),
|
|
602
|
+
},
|
|
603
|
+
)->S.castToUnknown
|
|
604
|
+
let schema' = schema->withSpec({...emptySpec, scanSort: ["name"]})
|
|
605
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
606
|
+
let nameSchema = getPropertyOf(json, "name")
|
|
607
|
+
let idSchema = getPropertyOf(json, "id")
|
|
608
|
+
expect((
|
|
609
|
+
nameSchema
|
|
610
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-scanSort"))
|
|
611
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
612
|
+
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-scanSort")),
|
|
613
|
+
))->toEqual((Some(true), None))
|
|
614
|
+
},
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
testSync(
|
|
618
|
+
"emits x-reventless-semantic on the field listed in semantic",
|
|
619
|
+
() => {
|
|
620
|
+
let schema = S.schema(
|
|
621
|
+
s =>
|
|
622
|
+
{
|
|
623
|
+
"id": s.matches(S.string),
|
|
624
|
+
"total": s.matches(S.float),
|
|
625
|
+
},
|
|
626
|
+
)->S.castToUnknown
|
|
627
|
+
let schema' = schema->withSpec({...emptySpec, semantic: [("total", "currency")]})
|
|
628
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
629
|
+
let totalSchema = getPropertyOf(json, "total")
|
|
630
|
+
let idSchema = getPropertyOf(json, "id")
|
|
631
|
+
expect((
|
|
632
|
+
totalSchema
|
|
633
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-semantic"))
|
|
634
|
+
->Option.flatMap(JSON.Decode.string),
|
|
635
|
+
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-semantic")),
|
|
636
|
+
))->toEqual((Some("currency"), None))
|
|
637
|
+
},
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
testSync(
|
|
641
|
+
"emits x-reventless-metric {aggregate,label} on the field listed in metric",
|
|
642
|
+
() => {
|
|
643
|
+
let schema = S.schema(
|
|
644
|
+
s =>
|
|
645
|
+
{
|
|
646
|
+
"id": s.matches(S.string),
|
|
647
|
+
"total": s.matches(S.float),
|
|
648
|
+
},
|
|
649
|
+
)->S.castToUnknown
|
|
650
|
+
let schema' = schema->withSpec({
|
|
580
651
|
...emptySpec,
|
|
581
|
-
|
|
652
|
+
metric: [("total", {aggregate: "sum", label: "Revenue"})],
|
|
582
653
|
})
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
654
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
655
|
+
let metricObj =
|
|
656
|
+
getPropertyOf(json, "total")->Option.flatMap(s => getProperty(s, "x-reventless-metric"))
|
|
657
|
+
expect((
|
|
658
|
+
metricObj
|
|
659
|
+
->Option.flatMap(m => getProperty(m, "aggregate"))
|
|
660
|
+
->Option.flatMap(JSON.Decode.string),
|
|
661
|
+
metricObj
|
|
662
|
+
->Option.flatMap(m => getProperty(m, "label"))
|
|
663
|
+
->Option.flatMap(JSON.Decode.string),
|
|
664
|
+
))->toEqual((Some("sum"), Some("Revenue")))
|
|
665
|
+
},
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
testSync(
|
|
669
|
+
"omits the metric label when empty (UI derives one from the field name)",
|
|
670
|
+
() => {
|
|
671
|
+
let schema = S.schema(s => {"total": s.matches(S.float)})->S.castToUnknown
|
|
672
|
+
let schema' =
|
|
673
|
+
schema->withSpec({...emptySpec, metric: [("total", {aggregate: "sum", label: ""})]})
|
|
674
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
675
|
+
let metricObj =
|
|
676
|
+
getPropertyOf(json, "total")->Option.flatMap(s => getProperty(s, "x-reventless-metric"))
|
|
677
|
+
expect((
|
|
678
|
+
metricObj
|
|
679
|
+
->Option.flatMap(m => getProperty(m, "aggregate"))
|
|
680
|
+
->Option.flatMap(JSON.Decode.string),
|
|
681
|
+
metricObj->Option.flatMap(m => getProperty(m, "label")),
|
|
682
|
+
))->toEqual((Some("sum"), None))
|
|
683
|
+
},
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
testSync(
|
|
687
|
+
"emits x-reventless-retired {label,showWhenFalse} on the retired field",
|
|
688
|
+
() => {
|
|
689
|
+
let schema = S.schema(
|
|
690
|
+
s =>
|
|
691
|
+
{
|
|
692
|
+
"id": s.matches(S.string),
|
|
693
|
+
"archived": s.matches(S.bool),
|
|
694
|
+
},
|
|
695
|
+
)->S.castToUnknown
|
|
696
|
+
let schema' = schema->withSpec({
|
|
697
|
+
...emptySpec,
|
|
698
|
+
retired: Some({
|
|
699
|
+
field: "archived",
|
|
700
|
+
label: "Archived",
|
|
701
|
+
showWhenFalse: true,
|
|
702
|
+
values: None,
|
|
703
|
+
namedWhenRetired: false,
|
|
704
|
+
}),
|
|
705
|
+
})
|
|
706
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
707
|
+
let retiredObj =
|
|
708
|
+
getPropertyOf(json, "archived")->Option.flatMap(
|
|
709
|
+
s => getProperty(s, "x-reventless-retired"),
|
|
710
|
+
)
|
|
711
|
+
expect((
|
|
712
|
+
retiredObj
|
|
713
|
+
->Option.flatMap(r => getProperty(r, "label"))
|
|
714
|
+
->Option.flatMap(JSON.Decode.string),
|
|
715
|
+
retiredObj
|
|
716
|
+
->Option.flatMap(r => getProperty(r, "showWhenFalse"))
|
|
717
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
718
|
+
// Only the named field carries it.
|
|
719
|
+
getPropertyOf(json, "id")->Option.flatMap(s => getProperty(s, "x-reventless-retired")),
|
|
720
|
+
))->toEqual((Some("Archived"), Some(true), None))
|
|
721
|
+
},
|
|
722
|
+
)
|
|
595
723
|
|
|
596
724
|
// The state form. `values` is what tells a consumer which of the two forms a
|
|
597
725
|
// view declared, so it travels on the same key and only when present.
|
|
598
726
|
let stateFormJson = (~values: array<string>) => {
|
|
599
|
-
let schema = S.schema(
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
727
|
+
let schema = S.schema(
|
|
728
|
+
s =>
|
|
729
|
+
{
|
|
730
|
+
"customerId": s.matches(S.string),
|
|
731
|
+
"accountStatus": s.matches(S.union([S.literal("Active"), S.literal("Deactivated")])),
|
|
732
|
+
},
|
|
604
733
|
)->S.castToUnknown
|
|
605
734
|
schema
|
|
606
735
|
->withSpec({
|
|
@@ -626,202 +755,261 @@ describe("SuryToJsonSchema:", () => {
|
|
|
626
755
|
->Option.flatMap(JSON.Decode.array)
|
|
627
756
|
->Option.map(a => a->Array.filterMap(JSON.Decode.string))
|
|
628
757
|
|
|
629
|
-
testSync(
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
758
|
+
testSync(
|
|
759
|
+
"carries one retirement state as a one-element set",
|
|
760
|
+
() => {
|
|
761
|
+
expect(
|
|
762
|
+
stateFormJson(~values=["Deactivated"])->retiredOn("accountStatus")->stringsAt("values"),
|
|
763
|
+
)->toEqual(Some(["Deactivated"]))
|
|
764
|
+
},
|
|
765
|
+
)
|
|
634
766
|
|
|
635
|
-
testSync(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
767
|
+
testSync(
|
|
768
|
+
"carries every retirement state a lifecycle declares",
|
|
769
|
+
() => {
|
|
770
|
+
expect(
|
|
771
|
+
stateFormJson(~values=["Archived", "Discontinued"])
|
|
772
|
+
->retiredOn("accountStatus")
|
|
773
|
+
->stringsAt("values"),
|
|
774
|
+
)->toEqual(Some(["Archived", "Discontinued"]))
|
|
775
|
+
},
|
|
776
|
+
)
|
|
642
777
|
|
|
643
778
|
// The transitional key, for one release. A consumer pinned before the set
|
|
644
779
|
// degrades to nothing at all rather than to a missing badge — and enforcement
|
|
645
780
|
// never depended on it, so the whole of the lag is cosmetic either way.
|
|
646
|
-
testSync(
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
781
|
+
testSync(
|
|
782
|
+
"emits the singular value beside a one-element set",
|
|
783
|
+
() => {
|
|
784
|
+
expect(
|
|
785
|
+
stateFormJson(~values=["Deactivated"])
|
|
786
|
+
->retiredOn("accountStatus")
|
|
787
|
+
->Option.flatMap(r => getProperty(r, "value"))
|
|
788
|
+
->Option.flatMap(JSON.Decode.string),
|
|
789
|
+
)->toEqual(Some("Deactivated"))
|
|
790
|
+
},
|
|
791
|
+
)
|
|
654
792
|
|
|
655
793
|
// There is no singular reading of two states, so the shim simply stops. A
|
|
656
794
|
// consumer that understands only `value` sees no retirement rather than one
|
|
657
795
|
// of the two picked arbitrarily.
|
|
658
|
-
testSync(
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
796
|
+
testSync(
|
|
797
|
+
"omits the singular value for a set of two",
|
|
798
|
+
() => {
|
|
799
|
+
expect(
|
|
800
|
+
stateFormJson(~values=["Archived", "Discontinued"])
|
|
801
|
+
->retiredOn("accountStatus")
|
|
802
|
+
->Option.flatMap(r => getProperty(r, "value")),
|
|
803
|
+
)->toEqual(None)
|
|
804
|
+
},
|
|
805
|
+
)
|
|
665
806
|
|
|
666
807
|
// Absent rather than null: a consumer reads the key's absence as "the boolean
|
|
667
808
|
// form", the same way it reads an absent label as "derive one from the name".
|
|
668
|
-
testSync(
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
schema
|
|
809
|
+
testSync(
|
|
810
|
+
"omits both value and values entirely on the boolean form",
|
|
811
|
+
() => {
|
|
812
|
+
let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
|
|
813
|
+
let schema' = schema->withSpec({
|
|
672
814
|
...emptySpec,
|
|
673
|
-
retired: Some({
|
|
815
|
+
retired: Some({
|
|
816
|
+
field: "archived",
|
|
817
|
+
label: "",
|
|
818
|
+
showWhenFalse: false,
|
|
819
|
+
values: None,
|
|
820
|
+
namedWhenRetired: false,
|
|
821
|
+
}),
|
|
674
822
|
})
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
823
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
824
|
+
let retiredObj = json->retiredOn("archived")
|
|
825
|
+
expect((
|
|
826
|
+
retiredObj->Option.flatMap(r => getProperty(r, "value")),
|
|
827
|
+
retiredObj->Option.flatMap(r => getProperty(r, "values")),
|
|
828
|
+
))->toEqual((None, None))
|
|
829
|
+
},
|
|
830
|
+
)
|
|
682
831
|
|
|
683
832
|
// The opt-in that lets a reference name a withheld row. Travels only when
|
|
684
833
|
// true, on the omit-the-default rule the keys around it follow: false is what
|
|
685
834
|
// every record said before it existed, and writing it would put a key on
|
|
686
835
|
// every retirement to report that nothing changed.
|
|
687
|
-
testSync(
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
836
|
+
testSync(
|
|
837
|
+
"carries namedWhenRetired only when the record opted in",
|
|
838
|
+
() => {
|
|
839
|
+
let build = (~named: bool) => {
|
|
840
|
+
let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
|
|
841
|
+
schema
|
|
842
|
+
->withSpec({
|
|
843
|
+
...emptySpec,
|
|
844
|
+
retired: Some({
|
|
845
|
+
field: "archived",
|
|
846
|
+
label: "",
|
|
847
|
+
showWhenFalse: false,
|
|
848
|
+
values: None,
|
|
849
|
+
namedWhenRetired: named,
|
|
850
|
+
}),
|
|
851
|
+
})
|
|
852
|
+
->SuryToJsonSchema.deriveObjectSchema
|
|
853
|
+
->retiredOn("archived")
|
|
854
|
+
->Option.flatMap(r => getProperty(r, "namedWhenRetired"))
|
|
855
|
+
->Option.flatMap(JSON.Decode.bool)
|
|
856
|
+
}
|
|
857
|
+
expect((build(~named=true), build(~named=false)))->toEqual((Some(true), None))
|
|
858
|
+
},
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
testSync(
|
|
862
|
+
"omits the retired label when empty but always states showWhenFalse",
|
|
863
|
+
() => {
|
|
864
|
+
let schema = S.schema(s => {"deactivated": s.matches(S.bool)})->S.castToUnknown
|
|
865
|
+
let schema' = schema->withSpec({
|
|
692
866
|
...emptySpec,
|
|
693
867
|
retired: Some({
|
|
694
|
-
field: "
|
|
868
|
+
field: "deactivated",
|
|
695
869
|
label: "",
|
|
696
870
|
showWhenFalse: false,
|
|
697
871
|
values: None,
|
|
698
|
-
namedWhenRetired:
|
|
872
|
+
namedWhenRetired: false,
|
|
699
873
|
}),
|
|
700
874
|
})
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
875
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
876
|
+
let retiredObj =
|
|
877
|
+
getPropertyOf(json, "deactivated")->Option.flatMap(
|
|
878
|
+
s => getProperty(s, "x-reventless-retired"),
|
|
879
|
+
)
|
|
880
|
+
expect((
|
|
881
|
+
retiredObj->Option.flatMap(r => getProperty(r, "label")),
|
|
882
|
+
retiredObj
|
|
883
|
+
->Option.flatMap(r => getProperty(r, "showWhenFalse"))
|
|
884
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
885
|
+
))->toEqual((None, Some(false)))
|
|
886
|
+
},
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
testSync(
|
|
890
|
+
"emits nothing for a schema with no retired annotation",
|
|
891
|
+
() => {
|
|
892
|
+
let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
|
|
893
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema->withSpec(emptySpec))
|
|
894
|
+
expect(
|
|
895
|
+
getPropertyOf(json, "archived")->Option.flatMap(
|
|
896
|
+
s => getProperty(s, "x-reventless-retired"),
|
|
897
|
+
),
|
|
898
|
+
)->toBe(None)
|
|
899
|
+
},
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
testSync(
|
|
903
|
+
"emits x-reventless-group-by on the field named by groupBy",
|
|
904
|
+
() => {
|
|
905
|
+
let schema = S.schema(
|
|
906
|
+
s =>
|
|
907
|
+
{
|
|
908
|
+
"id": s.matches(S.string),
|
|
909
|
+
"kind": s.matches(S.string),
|
|
910
|
+
},
|
|
911
|
+
)->S.castToUnknown
|
|
912
|
+
let schema' = schema->withSpec({...emptySpec, groupBy: Some("kind")})
|
|
913
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
914
|
+
let kindSchema = getPropertyOf(json, "kind")
|
|
915
|
+
let idSchema = getPropertyOf(json, "id")
|
|
916
|
+
expect((
|
|
917
|
+
kindSchema
|
|
918
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-group-by"))
|
|
919
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
920
|
+
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-group-by")),
|
|
921
|
+
))->toEqual((Some(true), None))
|
|
922
|
+
},
|
|
923
|
+
)
|
|
708
924
|
|
|
709
|
-
testSync(
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
schema
|
|
925
|
+
testSync(
|
|
926
|
+
"emits x-reventless-collapsed on field listed in collapsed",
|
|
927
|
+
() => {
|
|
928
|
+
let schema = S.schema(
|
|
929
|
+
s =>
|
|
930
|
+
{
|
|
931
|
+
"id": s.matches(S.string),
|
|
932
|
+
"primaryResource": s.matches(S.string),
|
|
933
|
+
},
|
|
934
|
+
)->S.castToUnknown
|
|
935
|
+
let schema' = schema->withSpec({...emptySpec, collapsed: ["primaryResource"]})
|
|
936
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
937
|
+
let primarySchema = getPropertyOf(json, "primaryResource")
|
|
938
|
+
let idSchema = getPropertyOf(json, "id")
|
|
939
|
+
expect((
|
|
940
|
+
primarySchema
|
|
941
|
+
->Option.flatMap(s => getProperty(s, "x-reventless-collapsed"))
|
|
942
|
+
->Option.flatMap(JSON.Decode.bool),
|
|
943
|
+
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-collapsed")),
|
|
944
|
+
))->toEqual((Some(true), None))
|
|
945
|
+
},
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
testSync(
|
|
949
|
+
"emits x-reventless-visibility at top level when visibility is Internal",
|
|
950
|
+
() => {
|
|
951
|
+
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
952
|
+
let schema' = schema->withSpec({...emptySpec, visibility: Some("Internal")})
|
|
953
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
954
|
+
expect(
|
|
955
|
+
getProperty(json, "x-reventless-visibility")->Option.flatMap(JSON.Decode.string),
|
|
956
|
+
)->toBe(Some("Internal"))
|
|
957
|
+
},
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
testSync(
|
|
961
|
+
"omits x-reventless-visibility when visibility is None (default Public)",
|
|
962
|
+
() => {
|
|
963
|
+
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
964
|
+
let schema' = schema->withSpec({...emptySpec, visibility: None})
|
|
965
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
966
|
+
expect(getProperty(json, "x-reventless-visibility"))->toBe(None)
|
|
967
|
+
},
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
testSync(
|
|
971
|
+
"emits x-reventless-live at top level when live is declared",
|
|
972
|
+
() => {
|
|
973
|
+
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
974
|
+
let offJson = SuryToJsonSchema.deriveObjectSchema(
|
|
975
|
+
schema->withSpec({...emptySpec, live: Some(false)}),
|
|
976
|
+
)
|
|
977
|
+
let onJson = SuryToJsonSchema.deriveObjectSchema(
|
|
978
|
+
schema->withSpec({...emptySpec, live: Some(true)}),
|
|
979
|
+
)
|
|
980
|
+
expect((
|
|
981
|
+
getProperty(offJson, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
|
|
982
|
+
getProperty(onJson, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
|
|
983
|
+
))->toEqual((Some(false), Some(true)))
|
|
984
|
+
},
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
testSync(
|
|
988
|
+
"omits x-reventless-live when live is None (no annotation)",
|
|
989
|
+
() => {
|
|
990
|
+
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
991
|
+
let schema' = schema->withSpec({...emptySpec, live: None})
|
|
992
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
993
|
+
expect(getProperty(json, "x-reventless-live"))->toBe(None)
|
|
994
|
+
},
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
testSync(
|
|
998
|
+
"emits x-reventless-live and x-reventless-visibility together",
|
|
999
|
+
() => {
|
|
1000
|
+
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
1001
|
+
let schema' = schema->withSpec({
|
|
713
1002
|
...emptySpec,
|
|
714
|
-
|
|
1003
|
+
visibility: Some("Internal"),
|
|
1004
|
+
live: Some(false),
|
|
715
1005
|
})
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
getProperty(
|
|
720
|
-
)
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
retiredObj
|
|
724
|
-
->Option.flatMap(r => getProperty(r, "showWhenFalse"))
|
|
725
|
-
->Option.flatMap(JSON.Decode.bool),
|
|
726
|
-
))->toEqual((None, Some(false)))
|
|
727
|
-
})
|
|
728
|
-
|
|
729
|
-
testSync("emits nothing for a schema with no retired annotation", () => {
|
|
730
|
-
let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
|
|
731
|
-
let json = SuryToJsonSchema.deriveObjectSchema(schema->withSpec(emptySpec))
|
|
732
|
-
expect(
|
|
733
|
-
getPropertyOf(json, "archived")->Option.flatMap(s => getProperty(s, "x-reventless-retired")),
|
|
734
|
-
)->toBe(None)
|
|
735
|
-
})
|
|
736
|
-
|
|
737
|
-
testSync("emits x-reventless-group-by on the field named by groupBy", () => {
|
|
738
|
-
let schema = S.schema(s =>
|
|
739
|
-
{
|
|
740
|
-
"id": s.matches(S.string),
|
|
741
|
-
"kind": s.matches(S.string),
|
|
742
|
-
}
|
|
743
|
-
)->S.castToUnknown
|
|
744
|
-
let schema' = schema->withSpec({...emptySpec, groupBy: Some("kind")})
|
|
745
|
-
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
746
|
-
let kindSchema = getPropertyOf(json, "kind")
|
|
747
|
-
let idSchema = getPropertyOf(json, "id")
|
|
748
|
-
expect((
|
|
749
|
-
kindSchema
|
|
750
|
-
->Option.flatMap(s => getProperty(s, "x-reventless-group-by"))
|
|
751
|
-
->Option.flatMap(JSON.Decode.bool),
|
|
752
|
-
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-group-by")),
|
|
753
|
-
))->toEqual((Some(true), None))
|
|
754
|
-
})
|
|
755
|
-
|
|
756
|
-
testSync("emits x-reventless-collapsed on field listed in collapsed", () => {
|
|
757
|
-
let schema = S.schema(s =>
|
|
758
|
-
{
|
|
759
|
-
"id": s.matches(S.string),
|
|
760
|
-
"primaryResource": s.matches(S.string),
|
|
761
|
-
}
|
|
762
|
-
)->S.castToUnknown
|
|
763
|
-
let schema' = schema->withSpec({...emptySpec, collapsed: ["primaryResource"]})
|
|
764
|
-
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
765
|
-
let primarySchema = getPropertyOf(json, "primaryResource")
|
|
766
|
-
let idSchema = getPropertyOf(json, "id")
|
|
767
|
-
expect((
|
|
768
|
-
primarySchema
|
|
769
|
-
->Option.flatMap(s => getProperty(s, "x-reventless-collapsed"))
|
|
770
|
-
->Option.flatMap(JSON.Decode.bool),
|
|
771
|
-
idSchema->Option.flatMap(s => getProperty(s, "x-reventless-collapsed")),
|
|
772
|
-
))->toEqual((Some(true), None))
|
|
773
|
-
})
|
|
774
|
-
|
|
775
|
-
testSync("emits x-reventless-visibility at top level when visibility is Internal", () => {
|
|
776
|
-
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
777
|
-
let schema' = schema->withSpec({...emptySpec, visibility: Some("Internal")})
|
|
778
|
-
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
779
|
-
expect(
|
|
780
|
-
getProperty(json, "x-reventless-visibility")->Option.flatMap(JSON.Decode.string),
|
|
781
|
-
)->toBe(Some("Internal"))
|
|
782
|
-
})
|
|
783
|
-
|
|
784
|
-
testSync("omits x-reventless-visibility when visibility is None (default Public)", () => {
|
|
785
|
-
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
786
|
-
let schema' = schema->withSpec({...emptySpec, visibility: None})
|
|
787
|
-
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
788
|
-
expect(getProperty(json, "x-reventless-visibility"))->toBe(None)
|
|
789
|
-
})
|
|
790
|
-
|
|
791
|
-
testSync("emits x-reventless-live at top level when live is declared", () => {
|
|
792
|
-
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
793
|
-
let offJson = SuryToJsonSchema.deriveObjectSchema(
|
|
794
|
-
schema->withSpec({...emptySpec, live: Some(false)}),
|
|
795
|
-
)
|
|
796
|
-
let onJson = SuryToJsonSchema.deriveObjectSchema(
|
|
797
|
-
schema->withSpec({...emptySpec, live: Some(true)}),
|
|
798
|
-
)
|
|
799
|
-
expect((
|
|
800
|
-
getProperty(offJson, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
|
|
801
|
-
getProperty(onJson, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
|
|
802
|
-
))->toEqual((Some(false), Some(true)))
|
|
803
|
-
})
|
|
804
|
-
|
|
805
|
-
testSync("omits x-reventless-live when live is None (no annotation)", () => {
|
|
806
|
-
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
807
|
-
let schema' = schema->withSpec({...emptySpec, live: None})
|
|
808
|
-
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
809
|
-
expect(getProperty(json, "x-reventless-live"))->toBe(None)
|
|
810
|
-
})
|
|
811
|
-
|
|
812
|
-
testSync("emits x-reventless-live and x-reventless-visibility together", () => {
|
|
813
|
-
let schema = S.schema(s => {"id": s.matches(S.string)})->S.castToUnknown
|
|
814
|
-
let schema' = schema->withSpec({
|
|
815
|
-
...emptySpec,
|
|
816
|
-
visibility: Some("Internal"),
|
|
817
|
-
live: Some(false),
|
|
818
|
-
})
|
|
819
|
-
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
820
|
-
expect((
|
|
821
|
-
getProperty(json, "x-reventless-visibility")->Option.flatMap(JSON.Decode.string),
|
|
822
|
-
getProperty(json, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
|
|
823
|
-
))->toEqual((Some("Internal"), Some(false)))
|
|
824
|
-
})
|
|
1006
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema')
|
|
1007
|
+
expect((
|
|
1008
|
+
getProperty(json, "x-reventless-visibility")->Option.flatMap(JSON.Decode.string),
|
|
1009
|
+
getProperty(json, "x-reventless-live")->Option.flatMap(JSON.Decode.bool),
|
|
1010
|
+
))->toEqual((Some("Internal"), Some(false)))
|
|
1011
|
+
},
|
|
1012
|
+
)
|
|
825
1013
|
})
|
|
826
1014
|
|
|
827
1015
|
// Parity sanity-check: deriveObjectSchema and the legacy S.toJSONSchema must
|
|
@@ -840,50 +1028,59 @@ describe("SuryToJsonSchema:", () => {
|
|
|
840
1028
|
| None => []
|
|
841
1029
|
}
|
|
842
1030
|
|
|
843
|
-
let sortStrings = (xs: array<string>): array<string> =>
|
|
844
|
-
|
|
1031
|
+
let sortStrings = (xs: array<string>): array<string> => xs->Array.toSorted(String.compare)
|
|
1032
|
+
|
|
1033
|
+
testSync(
|
|
1034
|
+
"properties keyset matches between deriveObjectSchema and S.toJSONSchema",
|
|
1035
|
+
() => {
|
|
1036
|
+
let schema = S.schema(
|
|
1037
|
+
s =>
|
|
1038
|
+
{
|
|
1039
|
+
"id": s.matches(S.string),
|
|
1040
|
+
"name": s.matches(S.string),
|
|
1041
|
+
"count": s.matches(S.int),
|
|
1042
|
+
},
|
|
1043
|
+
)->S.castToUnknown
|
|
1044
|
+
let derived = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1045
|
+
let native = (schema->S.toJSONSchema->Obj.magic: JSON.t)
|
|
1046
|
+
expect(derived->getKeys->sortStrings)->toEqual(native->getKeys->sortStrings)
|
|
1047
|
+
},
|
|
1048
|
+
)
|
|
845
1049
|
|
|
846
|
-
testSync(
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1050
|
+
testSync(
|
|
1051
|
+
"required array matches between deriveObjectSchema and S.toJSONSchema",
|
|
1052
|
+
() => {
|
|
1053
|
+
let schema = S.schema(
|
|
1054
|
+
s =>
|
|
1055
|
+
{
|
|
1056
|
+
"id": s.matches(S.string),
|
|
1057
|
+
"name": s.matches(S.string),
|
|
1058
|
+
},
|
|
1059
|
+
)->S.castToUnknown
|
|
1060
|
+
let derived = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1061
|
+
let native = (schema->S.toJSONSchema->Obj.magic: JSON.t)
|
|
1062
|
+
expect(derived->getRequired->sortStrings)->toEqual(native->getRequired->sortStrings)
|
|
1063
|
+
},
|
|
1064
|
+
)
|
|
858
1065
|
|
|
859
|
-
testSync(
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
"name": s.matches(S.string),
|
|
878
|
-
}
|
|
879
|
-
)->S.castToUnknown
|
|
880
|
-
let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
|
|
881
|
-
let native = (schema'->S.toJSONSchema->Obj.magic: JSON.t)
|
|
882
|
-
let entityIdSchema = getPropertyOf(native, "entityId")
|
|
883
|
-
expect(
|
|
884
|
-
entityIdSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")),
|
|
885
|
-
)->toBe(None)
|
|
886
|
-
})
|
|
1066
|
+
testSync(
|
|
1067
|
+
"S.toJSONSchema does NOT emit x-reventless-* keys even when metadata is set",
|
|
1068
|
+
() => {
|
|
1069
|
+
let withSpec = (schema, spec) =>
|
|
1070
|
+
schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
|
|
1071
|
+
let schema = S.schema(
|
|
1072
|
+
s =>
|
|
1073
|
+
{
|
|
1074
|
+
"entityId": s.matches(S.string),
|
|
1075
|
+
"name": s.matches(S.string),
|
|
1076
|
+
},
|
|
1077
|
+
)->S.castToUnknown
|
|
1078
|
+
let schema' = schema->withSpec({...emptySpec, ids: ["entityId"]})
|
|
1079
|
+
let native = (schema'->S.toJSONSchema->Obj.magic: JSON.t)
|
|
1080
|
+
let entityIdSchema = getPropertyOf(native, "entityId")
|
|
1081
|
+
expect(entityIdSchema->Option.flatMap(s => getProperty(s, "x-reventless-id")))->toBe(None)
|
|
1082
|
+
},
|
|
1083
|
+
)
|
|
887
1084
|
})
|
|
888
1085
|
|
|
889
1086
|
// Reference-ness and DCB-tagged-ness are separate facts that happen to
|
|
@@ -896,51 +1093,65 @@ describe("SuryToJsonSchema:", () => {
|
|
|
896
1093
|
let formatOf = (schema: JSON.t, fieldName: string): option<JSON.t> =>
|
|
897
1094
|
getPropertyOf(schema, fieldName)->Option.flatMap(s => getProperty(s, "format"))
|
|
898
1095
|
|
|
899
|
-
testSync(
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1096
|
+
testSync(
|
|
1097
|
+
"a DCB-tagged field with no reference is still an entity id",
|
|
1098
|
+
() => {
|
|
1099
|
+
let schema = S.schema(
|
|
1100
|
+
s =>
|
|
1101
|
+
{
|
|
1102
|
+
"orderId": s.matches(Reventless.DcbTag.string),
|
|
1103
|
+
},
|
|
1104
|
+
)->S.castToUnknown
|
|
1105
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1106
|
+
expect(formatOf(json, "orderId"))->toEqual(Some(JSON.Encode.string("uuid")))
|
|
1107
|
+
expect(Reventless.Reference.getTarget(schema))->toBe(None)
|
|
1108
|
+
},
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
testSync(
|
|
1112
|
+
"a reference without a DCB tag is still an entity id",
|
|
1113
|
+
() => {
|
|
1114
|
+
let field = Reventless.Reference.toWithoutDcbTag("Customer")
|
|
1115
|
+
let schema = S.schema(s => {"customerId": s.matches(field)})->S.castToUnknown
|
|
1116
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1117
|
+
expect(formatOf(json, "customerId"))->toEqual(Some(JSON.Encode.string("uuid")))
|
|
1118
|
+
expect(Reventless.DcbTag.isTagged(field->S.castToUnknown))->toBe(false)
|
|
1119
|
+
},
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
testSync(
|
|
1123
|
+
"Reference.to_ carries both facts",
|
|
1124
|
+
() => {
|
|
1125
|
+
let field = Reventless.Reference.to_("Customer")
|
|
1126
|
+
expect(Reventless.DcbTag.isTagged(field->S.castToUnknown))->toBe(true)
|
|
1127
|
+
expect(
|
|
1128
|
+
Reventless.Reference.getTarget(field->S.castToUnknown)->Option.map(t => t.entity),
|
|
1129
|
+
)->toEqual(Some("Customer"))
|
|
1130
|
+
},
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
testSync(
|
|
1134
|
+
"neither reference nor tag emits x-reventless-semantic on the wire",
|
|
1135
|
+
() => {
|
|
1136
|
+
// `dateTime` and `reference` predate the generic marker and keep emitting
|
|
1137
|
+
// `format`; adding the semantic key for them would change a published
|
|
1138
|
+
// contract. Only semantics without a dedicated shape surface as the key.
|
|
1139
|
+
let schema = S.schema(
|
|
1140
|
+
s =>
|
|
1141
|
+
{
|
|
1142
|
+
"customerId": s.matches(Reventless.Reference.to_("Customer")),
|
|
1143
|
+
"placedAt": s.matches(Reventless.DateTime.schema),
|
|
1144
|
+
},
|
|
1145
|
+
)->S.castToUnknown
|
|
1146
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1147
|
+
let semanticOf = fieldName =>
|
|
1148
|
+
getPropertyOf(json, fieldName)->Option.flatMap(
|
|
1149
|
+
s => getProperty(s, "x-reventless-semantic"),
|
|
1150
|
+
)
|
|
1151
|
+
expect(semanticOf("customerId"))->toBe(None)
|
|
1152
|
+
expect(semanticOf("placedAt"))->toBe(None)
|
|
1153
|
+
},
|
|
1154
|
+
)
|
|
944
1155
|
})
|
|
945
1156
|
|
|
946
1157
|
// The reason the generic marker exists. `x-reventless-semantic` used to be
|
|
@@ -952,83 +1163,108 @@ describe("SuryToJsonSchema:", () => {
|
|
|
952
1163
|
let semanticOf = (json, fieldName) =>
|
|
953
1164
|
getPropertyOf(json, fieldName)->Option.flatMap(s => getProperty(s, "x-reventless-semantic"))
|
|
954
1165
|
|
|
955
|
-
testSync(
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1166
|
+
testSync(
|
|
1167
|
+
"a storage-ref command field emits the semantic id",
|
|
1168
|
+
() => {
|
|
1169
|
+
let schema = S.schema(
|
|
1170
|
+
s =>
|
|
1171
|
+
{
|
|
1172
|
+
"productId": s.matches(S.string),
|
|
1173
|
+
"imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages")),
|
|
1174
|
+
},
|
|
1175
|
+
)->S.castToUnknown
|
|
1176
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1177
|
+
expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
|
|
1178
|
+
expect(semanticOf(json, "productId"))->toBe(None)
|
|
1179
|
+
},
|
|
1180
|
+
)
|
|
966
1181
|
|
|
967
|
-
testSync(
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1182
|
+
testSync(
|
|
1183
|
+
"it carries the store identity and marks the type as its source",
|
|
1184
|
+
() => {
|
|
1185
|
+
let schema = S.schema(
|
|
1186
|
+
s =>
|
|
1187
|
+
{
|
|
1188
|
+
"imageUrl": s.matches(
|
|
1189
|
+
Reventless.StorageRef.forStore(~plugin="catalog", ~store="productImages"),
|
|
1190
|
+
),
|
|
1191
|
+
},
|
|
1192
|
+
)->S.castToUnknown
|
|
1193
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1194
|
+
let field = getPropertyOf(json, "imageUrl")
|
|
1195
|
+
expect(field->Option.flatMap(s => getProperty(s, "x-reventless-semantic-source")))->toEqual(
|
|
1196
|
+
Some(JSON.Encode.string("type")),
|
|
1197
|
+
)
|
|
1198
|
+
expect(field->Option.flatMap(s => getProperty(s, "x-reventless-semantic-target")))->toEqual(
|
|
1199
|
+
Some(
|
|
1200
|
+
JSON.Encode.object(
|
|
1201
|
+
Dict.fromArray([
|
|
1202
|
+
("store", JSON.Encode.string("productImages")),
|
|
1203
|
+
("plugin", JSON.Encode.string("catalog")),
|
|
1204
|
+
]),
|
|
1205
|
+
),
|
|
987
1206
|
),
|
|
988
|
-
)
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
testSync(
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1207
|
+
)
|
|
1208
|
+
},
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
testSync(
|
|
1212
|
+
"the underlying string shape is unchanged",
|
|
1213
|
+
() => {
|
|
1214
|
+
// The marker refines an existing `string` field; nothing about what is
|
|
1215
|
+
// stored changes, which is why it can be retrofitted onto a live schema.
|
|
1216
|
+
let schema =
|
|
1217
|
+
S.schema(
|
|
1218
|
+
s => {"imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages"))},
|
|
1219
|
+
)->S.castToUnknown
|
|
1220
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1221
|
+
expect(
|
|
1222
|
+
getPropertyOf(json, "imageUrl")->Option.flatMap(s => getProperty(s, "type")),
|
|
1223
|
+
)->toEqual(Some(JSON.Encode.string("string")))
|
|
1224
|
+
},
|
|
1225
|
+
)
|
|
1003
1226
|
|
|
1004
1227
|
// An optional field's marker sits inside the wrapper sury-ppx builds around
|
|
1005
1228
|
// it, so this used to emit a plain nullable string: the field was a storage
|
|
1006
1229
|
// ref in the source and an untyped string on the wire. The semantic now
|
|
1007
1230
|
// survives, and it rides *beside* the nullable shape rather than replacing
|
|
1008
1231
|
// it — a reader still learns the value may be absent.
|
|
1009
|
-
testSync(
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
)
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1232
|
+
testSync(
|
|
1233
|
+
"an optional storage-ref field keeps both its semantic and its nullability",
|
|
1234
|
+
() => {
|
|
1235
|
+
let schema = S.schema(
|
|
1236
|
+
s =>
|
|
1237
|
+
{
|
|
1238
|
+
"imageUrl": s.matches(
|
|
1239
|
+
S.option(Reventless.StorageRef.forStore(~store="productImages")),
|
|
1240
|
+
),
|
|
1241
|
+
},
|
|
1242
|
+
)->S.castToUnknown
|
|
1243
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1244
|
+
expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
|
|
1245
|
+
expect(
|
|
1246
|
+
getPropertyOf(json, "imageUrl")
|
|
1247
|
+
->Option.flatMap(s => getProperty(s, "oneOf"))
|
|
1248
|
+
->Option.isSome,
|
|
1249
|
+
)->toBe(true)
|
|
1250
|
+
},
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
testSync(
|
|
1254
|
+
"a type-carried semantic beats an annotation naming the same field",
|
|
1255
|
+
() => {
|
|
1256
|
+
let withSpec = (schema, spec) =>
|
|
1257
|
+
schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
|
|
1258
|
+
let schema =
|
|
1259
|
+
S.schema(
|
|
1260
|
+
s => {"imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages"))},
|
|
1261
|
+
)
|
|
1262
|
+
->S.castToUnknown
|
|
1263
|
+
->withSpec({...emptySpec, semantic: [("imageUrl", "image")]})
|
|
1264
|
+
let json = SuryToJsonSchema.deriveObjectSchema(schema)
|
|
1265
|
+
expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
|
|
1266
|
+
},
|
|
1267
|
+
)
|
|
1032
1268
|
})
|
|
1033
1269
|
|
|
1034
1270
|
// A selection, not an input. The whole point of the id is that it travels the
|
|
@@ -1036,83 +1272,93 @@ describe("SuryToJsonSchema:", () => {
|
|
|
1036
1272
|
// cannot: the candidates are on the row, so nothing is provisioned for it.
|
|
1037
1273
|
describe("a member-reference field:", () => {
|
|
1038
1274
|
let targetOf = (json, name) =>
|
|
1039
|
-
getPropertyOf(json, name)->Option.flatMap(s =>
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1275
|
+
getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-semantic-target"))
|
|
1276
|
+
|
|
1277
|
+
testSync(
|
|
1278
|
+
"carries the collection, the view holding it, and what a member is",
|
|
1279
|
+
() => {
|
|
1280
|
+
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
1281
|
+
S.schema(
|
|
1282
|
+
s =>
|
|
1283
|
+
{
|
|
1284
|
+
"productImage": s.matches(
|
|
1285
|
+
Reventless.MemberRef.of_(
|
|
1286
|
+
~view="Products",
|
|
1287
|
+
~content=Reventless.Semantic.Id.imageRef,
|
|
1288
|
+
~field="productImages",
|
|
1289
|
+
),
|
|
1290
|
+
),
|
|
1291
|
+
},
|
|
1292
|
+
)->S.castToUnknown,
|
|
1293
|
+
)
|
|
1294
|
+
expect(
|
|
1295
|
+
getPropertyOf(json, "productImage")->Option.flatMap(
|
|
1296
|
+
s => getProperty(s, "x-reventless-semantic"),
|
|
1297
|
+
),
|
|
1298
|
+
)->toEqual(Some(JSON.Encode.string("memberRef")))
|
|
1299
|
+
expect(targetOf(json, "productImage"))->toEqual(
|
|
1300
|
+
Some(
|
|
1301
|
+
JSON.Encode.object(
|
|
1302
|
+
Dict.fromArray([
|
|
1303
|
+
("field", JSON.Encode.string("productImages")),
|
|
1304
|
+
("view", JSON.Encode.string("Products")),
|
|
1305
|
+
("content", JSON.Encode.string("imageRef")),
|
|
1306
|
+
]),
|
|
1053
1307
|
),
|
|
1054
|
-
}
|
|
1055
|
-
)->S.castToUnknown,
|
|
1056
|
-
)
|
|
1057
|
-
expect(
|
|
1058
|
-
getPropertyOf(json, "productImage")->Option.flatMap(s =>
|
|
1059
|
-
getProperty(s, "x-reventless-semantic")
|
|
1060
|
-
),
|
|
1061
|
-
)->toEqual(Some(JSON.Encode.string("memberRef")))
|
|
1062
|
-
expect(targetOf(json, "productImage"))->toEqual(
|
|
1063
|
-
Some(
|
|
1064
|
-
JSON.Encode.object(
|
|
1065
|
-
Dict.fromArray([
|
|
1066
|
-
("field", JSON.Encode.string("productImages")),
|
|
1067
|
-
("view", JSON.Encode.string("Products")),
|
|
1068
|
-
("content", JSON.Encode.string("imageRef")),
|
|
1069
|
-
]),
|
|
1070
1308
|
),
|
|
1071
|
-
)
|
|
1072
|
-
|
|
1073
|
-
|
|
1309
|
+
)
|
|
1310
|
+
},
|
|
1311
|
+
)
|
|
1074
1312
|
|
|
1075
1313
|
// The second position: a declaration on a view's own state, where the
|
|
1076
1314
|
// collection is on the same record and naming a view would invite the reader
|
|
1077
1315
|
// to think another one could be meant.
|
|
1078
|
-
testSync(
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1316
|
+
testSync(
|
|
1317
|
+
"omits the view where the collection is on this very record",
|
|
1318
|
+
() =>
|
|
1319
|
+
expect(
|
|
1320
|
+
targetOf(
|
|
1321
|
+
SuryToJsonSchema.deriveObjectSchema(
|
|
1322
|
+
S.schema(
|
|
1323
|
+
s => {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))},
|
|
1324
|
+
)->S.castToUnknown,
|
|
1325
|
+
),
|
|
1326
|
+
"productImage",
|
|
1327
|
+
),
|
|
1328
|
+
)->toEqual(
|
|
1329
|
+
Some(
|
|
1330
|
+
JSON.Encode.object(Dict.fromArray([("field", JSON.Encode.string("productImages"))])),
|
|
1085
1331
|
),
|
|
1086
|
-
"productImage",
|
|
1087
1332
|
),
|
|
1088
|
-
)->toEqual(
|
|
1089
|
-
Some(JSON.Encode.object(Dict.fromArray([("field", JSON.Encode.string("productImages"))]))),
|
|
1090
|
-
)
|
|
1091
1333
|
)
|
|
1092
1334
|
|
|
1093
1335
|
// The assertion the whole design rests on, and the one a reader will want to
|
|
1094
1336
|
// see rather than infer: a `memberRef` field declares NO store, so the
|
|
1095
1337
|
// provisioning walk passes it by and no upload endpoint is bound. This is
|
|
1096
1338
|
// exactly what `imageRef` gets today, reached through a different payload.
|
|
1097
|
-
testSync(
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1339
|
+
testSync(
|
|
1340
|
+
"declares no store, so nothing is provisioned for it",
|
|
1341
|
+
() =>
|
|
1342
|
+
expect(
|
|
1343
|
+
Reventless.StorageRef.getFieldStore(
|
|
1344
|
+
Reventless.MemberRef.of_(~view="Products", ~field="productImages"),
|
|
1345
|
+
),
|
|
1346
|
+
)->toBe(None),
|
|
1103
1347
|
)
|
|
1104
1348
|
|
|
1105
|
-
testSync(
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1349
|
+
testSync(
|
|
1350
|
+
"stays a plain string on the wire",
|
|
1351
|
+
() =>
|
|
1352
|
+
expect(
|
|
1353
|
+
getPropertyOf(
|
|
1354
|
+
SuryToJsonSchema.deriveObjectSchema(
|
|
1355
|
+
S.schema(
|
|
1356
|
+
s => {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))},
|
|
1357
|
+
)->S.castToUnknown,
|
|
1358
|
+
),
|
|
1359
|
+
"productImage",
|
|
1360
|
+
)->Option.flatMap(s => getProperty(s, "type")),
|
|
1361
|
+
)->toEqual(Some(JSON.Encode.string("string"))),
|
|
1116
1362
|
)
|
|
1117
1363
|
})
|
|
1118
1364
|
|
|
@@ -1126,35 +1372,42 @@ describe("SuryToJsonSchema:", () => {
|
|
|
1126
1372
|
let keyOf = (json, name, key) => fieldOf(json, name)->Option.flatMap(s => getProperty(s, key))
|
|
1127
1373
|
|
|
1128
1374
|
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
1129
|
-
S.schema(
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1375
|
+
S.schema(
|
|
1376
|
+
s =>
|
|
1377
|
+
{
|
|
1378
|
+
"contactEmail": s.matches(Reventless.Email.schema),
|
|
1379
|
+
"taxRate": s.matches(Reventless.Percent.schema),
|
|
1380
|
+
},
|
|
1134
1381
|
)->S.castToUnknown,
|
|
1135
1382
|
)
|
|
1136
1383
|
|
|
1137
|
-
testSync(
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1384
|
+
testSync(
|
|
1385
|
+
"a string scalar stays a string and carries its id",
|
|
1386
|
+
() =>
|
|
1387
|
+
expect((
|
|
1388
|
+
keyOf(json, "contactEmail", "type"),
|
|
1389
|
+
keyOf(json, "contactEmail", "x-reventless-semantic"),
|
|
1390
|
+
))->toEqual((Some(JSON.Encode.string("string")), Some(JSON.Encode.string("email")))),
|
|
1142
1391
|
)
|
|
1143
1392
|
|
|
1144
|
-
testSync(
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1393
|
+
testSync(
|
|
1394
|
+
"a numeric scalar stays a number and carries its id",
|
|
1395
|
+
() =>
|
|
1396
|
+
expect((
|
|
1397
|
+
keyOf(json, "taxRate", "type"),
|
|
1398
|
+
keyOf(json, "taxRate", "x-reventless-semantic"),
|
|
1399
|
+
))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("percent")))),
|
|
1149
1400
|
)
|
|
1150
1401
|
|
|
1151
1402
|
// The provenance half: a typed field outranks an annotated one, and this is
|
|
1152
1403
|
// the key a consumer reads to tell them apart.
|
|
1153
|
-
testSync(
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1404
|
+
testSync(
|
|
1405
|
+
"both name the type as their source",
|
|
1406
|
+
() =>
|
|
1407
|
+
expect((
|
|
1408
|
+
keyOf(json, "contactEmail", "x-reventless-semantic-source"),
|
|
1409
|
+
keyOf(json, "taxRate", "x-reventless-semantic-source"),
|
|
1410
|
+
))->toEqual((Some(JSON.Encode.string("type")), Some(JSON.Encode.string("type")))),
|
|
1158
1411
|
)
|
|
1159
1412
|
})
|
|
1160
1413
|
|
|
@@ -1166,61 +1419,75 @@ describe("SuryToJsonSchema:", () => {
|
|
|
1166
1419
|
// says a deployment needs an upcaster before it can take the change.
|
|
1167
1420
|
describe("Money changes the field's shape, and says so:", () => {
|
|
1168
1421
|
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
1169
|
-
S.schema(
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1422
|
+
S.schema(
|
|
1423
|
+
s =>
|
|
1424
|
+
{
|
|
1425
|
+
"price": s.matches(Reventless.Money.schema),
|
|
1426
|
+
"quantity": s.matches(S.float),
|
|
1427
|
+
},
|
|
1174
1428
|
)->S.castToUnknown,
|
|
1175
1429
|
)
|
|
1176
1430
|
let keyOf = (name, key) => getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, key))
|
|
1177
1431
|
|
|
1178
|
-
testSync(
|
|
1179
|
-
|
|
1432
|
+
testSync(
|
|
1433
|
+
"the field is an object, not a number",
|
|
1434
|
+
() => expect(keyOf("price", "type"))->toEqual(Some(JSON.Encode.string("object"))),
|
|
1180
1435
|
)
|
|
1181
1436
|
|
|
1182
|
-
testSync(
|
|
1183
|
-
|
|
1184
|
-
|
|
1437
|
+
testSync(
|
|
1438
|
+
"it carries the money id, sourced from the type",
|
|
1439
|
+
() =>
|
|
1440
|
+
expect((
|
|
1441
|
+
keyOf("price", "x-reventless-semantic"),
|
|
1442
|
+
keyOf("price", "x-reventless-semantic-source"),
|
|
1443
|
+
))->toEqual((Some(JSON.Encode.string("money")), Some(JSON.Encode.string("type")))),
|
|
1185
1444
|
)
|
|
1186
1445
|
|
|
1187
1446
|
// The control: an ordinary `float` price is what the field looked like
|
|
1188
1447
|
// before, and it emits nothing at all. Without this the assertions above
|
|
1189
1448
|
// would pass just as well against a schema that had always said `money`.
|
|
1190
|
-
testSync(
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1449
|
+
testSync(
|
|
1450
|
+
"a plain numeric field emits no semantic",
|
|
1451
|
+
() =>
|
|
1452
|
+
expect((keyOf("quantity", "type"), keyOf("quantity", "x-reventless-semantic")))->toEqual((
|
|
1453
|
+
Some(JSON.Encode.string("number")),
|
|
1454
|
+
None,
|
|
1455
|
+
)),
|
|
1195
1456
|
)
|
|
1196
1457
|
|
|
1197
|
-
testSync(
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
getPropertyOf(
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1458
|
+
testSync(
|
|
1459
|
+
"the amount and the currency both reach the wire",
|
|
1460
|
+
() => {
|
|
1461
|
+
let inner = getPropertyOf(json, "price")->Option.getOr(JSON.Encode.null)
|
|
1462
|
+
expect((
|
|
1463
|
+
getPropertyOf(inner, "amount")->Option.flatMap(s => getProperty(s, "type")),
|
|
1464
|
+
getPropertyOf(inner, "currency")->Option.flatMap(s => getProperty(s, "type")),
|
|
1465
|
+
))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("string"))))
|
|
1466
|
+
},
|
|
1467
|
+
)
|
|
1204
1468
|
|
|
1205
1469
|
// The closed currency arrives as an enum rather than a free string, which is
|
|
1206
1470
|
// what lets a consumer build a currency picker instead of a text box — and
|
|
1207
1471
|
// what makes `"eur"` unrepresentable on the wire as well as in the source.
|
|
1208
|
-
testSync(
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1472
|
+
testSync(
|
|
1473
|
+
"the currency is an enum of ISO codes",
|
|
1474
|
+
() => {
|
|
1475
|
+
let codes =
|
|
1476
|
+
getPropertyOf(json, "price")
|
|
1477
|
+
->Option.getOr(JSON.Encode.null)
|
|
1478
|
+
->getPropertyOf("currency")
|
|
1479
|
+
->Option.flatMap(s => getProperty(s, "enum"))
|
|
1480
|
+
->Option.flatMap(JSON.Decode.array)
|
|
1481
|
+
->Option.getOr([])
|
|
1482
|
+
->Array.filterMap(JSON.Decode.string)
|
|
1483
|
+
expect((
|
|
1484
|
+
codes->Array.length == Reventless.Currency.all->Array.length,
|
|
1485
|
+
codes->Array.includes("EUR"),
|
|
1486
|
+
codes->Array.includes("JPY"),
|
|
1487
|
+
codes->Array.includes("eur"),
|
|
1488
|
+
))->toEqual((true, true, true, false))
|
|
1489
|
+
},
|
|
1490
|
+
)
|
|
1224
1491
|
})
|
|
1225
1492
|
|
|
1226
1493
|
// The second composite, and the one whose emission the reader's shape rung
|
|
@@ -1232,46 +1499,55 @@ describe("SuryToJsonSchema:", () => {
|
|
|
1232
1499
|
// before the `dateRange` marker reaches it in a release.
|
|
1233
1500
|
describe("DateRange is an object whose parts stay date-times:", () => {
|
|
1234
1501
|
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
1235
|
-
S.schema(
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1502
|
+
S.schema(
|
|
1503
|
+
s =>
|
|
1504
|
+
{
|
|
1505
|
+
"deliveryWindow": s.matches(Reventless.DateRange.schema),
|
|
1506
|
+
"orderedAt": s.matches(Reventless.DateTime.schema),
|
|
1507
|
+
},
|
|
1240
1508
|
)->S.castToUnknown,
|
|
1241
1509
|
)
|
|
1242
1510
|
let keyOf = (name, key) => getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, key))
|
|
1243
1511
|
|
|
1244
|
-
testSync(
|
|
1245
|
-
|
|
1512
|
+
testSync(
|
|
1513
|
+
"the field is an object, not a string",
|
|
1514
|
+
() => expect(keyOf("deliveryWindow", "type"))->toEqual(Some(JSON.Encode.string("object"))),
|
|
1246
1515
|
)
|
|
1247
1516
|
|
|
1248
|
-
testSync(
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1517
|
+
testSync(
|
|
1518
|
+
"it carries the dateRange id, sourced from the type",
|
|
1519
|
+
() =>
|
|
1520
|
+
expect((
|
|
1521
|
+
keyOf("deliveryWindow", "x-reventless-semantic"),
|
|
1522
|
+
keyOf("deliveryWindow", "x-reventless-semantic-source"),
|
|
1523
|
+
))->toEqual((Some(JSON.Encode.string("dateRange")), Some(JSON.Encode.string("type")))),
|
|
1253
1524
|
)
|
|
1254
1525
|
|
|
1255
1526
|
// The property that is not incidental: each part keeps its own date-time
|
|
1256
1527
|
// marker under the composite. `dateTime` predates the generic marker and
|
|
1257
1528
|
// surfaces as `format: "date-time"` (not `x-reventless-semantic`), so that is
|
|
1258
1529
|
// what a walker reads to see the two instants without knowing about ranges.
|
|
1259
|
-
testSync(
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
getPropertyOf(
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1530
|
+
testSync(
|
|
1531
|
+
"both instants keep their own date-time marker",
|
|
1532
|
+
() => {
|
|
1533
|
+
let inner = getPropertyOf(json, "deliveryWindow")->Option.getOr(JSON.Encode.null)
|
|
1534
|
+
expect((
|
|
1535
|
+
getPropertyOf(inner, "start")->Option.flatMap(s => getProperty(s, "format")),
|
|
1536
|
+
getPropertyOf(inner, "end")->Option.flatMap(s => getProperty(s, "format")),
|
|
1537
|
+
))->toEqual((Some(JSON.Encode.string("date-time")), Some(JSON.Encode.string("date-time"))))
|
|
1538
|
+
},
|
|
1539
|
+
)
|
|
1266
1540
|
|
|
1267
1541
|
// The control, mirroring the `Money` block's plain-float case: a lone
|
|
1268
1542
|
// date-time field stays a string and never becomes an object, so the
|
|
1269
1543
|
// object-ness above is the composite's doing and not the walk's.
|
|
1270
|
-
testSync(
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1544
|
+
testSync(
|
|
1545
|
+
"a lone date-time field stays a string",
|
|
1546
|
+
() =>
|
|
1547
|
+
expect((keyOf("orderedAt", "type"), keyOf("orderedAt", "format")))->toEqual((
|
|
1548
|
+
Some(JSON.Encode.string("string")),
|
|
1549
|
+
Some(JSON.Encode.string("date-time")),
|
|
1550
|
+
)),
|
|
1275
1551
|
)
|
|
1276
1552
|
})
|
|
1277
1553
|
|
|
@@ -1282,43 +1558,52 @@ describe("SuryToJsonSchema:", () => {
|
|
|
1282
1558
|
// separate a declaration from a lucky guess — the marker and its `type` source.
|
|
1283
1559
|
describe("GeoPoint is an object carrying the geoPoint marker:", () => {
|
|
1284
1560
|
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
1285
|
-
S.schema(
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1561
|
+
S.schema(
|
|
1562
|
+
s =>
|
|
1563
|
+
{
|
|
1564
|
+
"location": s.matches(Reventless.GeoPoint.schema),
|
|
1565
|
+
"lat": s.matches(S.float),
|
|
1566
|
+
},
|
|
1290
1567
|
)->S.castToUnknown,
|
|
1291
1568
|
)
|
|
1292
1569
|
let keyOf = (name, key) => getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, key))
|
|
1293
1570
|
|
|
1294
|
-
testSync(
|
|
1295
|
-
|
|
1571
|
+
testSync(
|
|
1572
|
+
"the field is an object, not a pair of numbers",
|
|
1573
|
+
() => expect(keyOf("location", "type"))->toEqual(Some(JSON.Encode.string("object"))),
|
|
1296
1574
|
)
|
|
1297
1575
|
|
|
1298
|
-
testSync(
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1576
|
+
testSync(
|
|
1577
|
+
"it carries the geoPoint id, sourced from the type",
|
|
1578
|
+
() =>
|
|
1579
|
+
expect((
|
|
1580
|
+
keyOf("location", "x-reventless-semantic"),
|
|
1581
|
+
keyOf("location", "x-reventless-semantic-source"),
|
|
1582
|
+
))->toEqual((Some(JSON.Encode.string("geoPoint")), Some(JSON.Encode.string("type")))),
|
|
1303
1583
|
)
|
|
1304
1584
|
|
|
1305
1585
|
// The control, and the whole reason the composite exists: a bare `lat` float
|
|
1306
1586
|
// is exactly what the flattened pair looked like, and it emits nothing at
|
|
1307
1587
|
// all. Everything a map knew about it, it inferred from the name.
|
|
1308
|
-
testSync(
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1588
|
+
testSync(
|
|
1589
|
+
"a bare lat float emits no semantic",
|
|
1590
|
+
() =>
|
|
1591
|
+
expect((keyOf("lat", "type"), keyOf("lat", "x-reventless-semantic")))->toEqual((
|
|
1592
|
+
Some(JSON.Encode.string("number")),
|
|
1593
|
+
None,
|
|
1594
|
+
)),
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
testSync(
|
|
1598
|
+
"both coordinates reach the wire as numbers",
|
|
1599
|
+
() => {
|
|
1600
|
+
let inner = getPropertyOf(json, "location")->Option.getOr(JSON.Encode.null)
|
|
1601
|
+
expect((
|
|
1602
|
+
getPropertyOf(inner, "lat")->Option.flatMap(s => getProperty(s, "type")),
|
|
1603
|
+
getPropertyOf(inner, "lng")->Option.flatMap(s => getProperty(s, "type")),
|
|
1604
|
+
))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("number"))))
|
|
1605
|
+
},
|
|
1606
|
+
)
|
|
1322
1607
|
})
|
|
1323
1608
|
|
|
1324
1609
|
// A nested record's own optional field. `optional` is computed for the schema
|
|
@@ -1326,18 +1611,20 @@ describe("SuryToJsonSchema:", () => {
|
|
|
1326
1611
|
// one level in has only its nullable wrapper to say it may be absent — and the
|
|
1327
1612
|
// wrapper is exactly what a reader looking at `required` never sees.
|
|
1328
1613
|
describe("an optional field inside a nested record:", () => {
|
|
1329
|
-
let inner = S.schema(
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1614
|
+
let inner = S.schema(
|
|
1615
|
+
s =>
|
|
1616
|
+
{
|
|
1617
|
+
"street": s.matches(S.string),
|
|
1618
|
+
"unit": s.matches(S.option(S.string)),
|
|
1619
|
+
},
|
|
1334
1620
|
)
|
|
1335
1621
|
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
1336
|
-
S.schema(
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1622
|
+
S.schema(
|
|
1623
|
+
s =>
|
|
1624
|
+
{
|
|
1625
|
+
"id": s.matches(S.string),
|
|
1626
|
+
"address": s.matches(inner),
|
|
1627
|
+
},
|
|
1341
1628
|
)->S.castToUnknown,
|
|
1342
1629
|
)
|
|
1343
1630
|
let nestedRequired =
|
|
@@ -1347,13 +1634,83 @@ describe("SuryToJsonSchema:", () => {
|
|
|
1347
1634
|
->Option.getOr([])
|
|
1348
1635
|
->Array.filterMap(JSON.Decode.string)
|
|
1349
1636
|
|
|
1350
|
-
testSync(
|
|
1351
|
-
|
|
1637
|
+
testSync(
|
|
1638
|
+
"is not listed as required",
|
|
1639
|
+
() => expect(nestedRequired->Array.includes("unit"))->toBe(false),
|
|
1352
1640
|
)
|
|
1353
1641
|
|
|
1354
|
-
testSync(
|
|
1355
|
-
|
|
1642
|
+
testSync(
|
|
1643
|
+
"while its non-optional sibling still is",
|
|
1644
|
+
() => expect(nestedRequired->Array.includes("street"))->toBe(true),
|
|
1356
1645
|
)
|
|
1357
1646
|
})
|
|
1358
1647
|
|
|
1648
|
+
// The value a field opens with is JSON Schema's own keyword, not an
|
|
1649
|
+
// `x-reventless-` extension — and it rides on the field's schema, so it
|
|
1650
|
+
// survives the one place a name-keyed annotation spec cannot reach: a record
|
|
1651
|
+
// nested inside a command.
|
|
1652
|
+
describe("a field declaring a default:", () => {
|
|
1653
|
+
let defaultOf = (json, ~field) =>
|
|
1654
|
+
getPropertyOf(json, field)->Option.flatMap(s => getProperty(s, "default"))
|
|
1655
|
+
|
|
1656
|
+
let json = SuryToJsonSchema.deriveObjectSchema(
|
|
1657
|
+
S.schema(
|
|
1658
|
+
s =>
|
|
1659
|
+
{
|
|
1660
|
+
"quantity": s.matches(S.int->Reventless.FieldDefault.int(1)),
|
|
1661
|
+
"shippingMethod": s.matches(S.string->Reventless.FieldDefault.string("Standard")),
|
|
1662
|
+
"plain": s.matches(S.int),
|
|
1663
|
+
},
|
|
1664
|
+
)->S.castToUnknown,
|
|
1665
|
+
)
|
|
1666
|
+
|
|
1667
|
+
testSync(
|
|
1668
|
+
"carries it as `default`",
|
|
1669
|
+
() => expect(defaultOf(json, ~field="quantity"))->toEqual(Some(JSON.Encode.int(1))),
|
|
1670
|
+
)
|
|
1671
|
+
|
|
1672
|
+
testSync(
|
|
1673
|
+
"keeps the shape the value actually has",
|
|
1674
|
+
() =>
|
|
1675
|
+
expect(
|
|
1676
|
+
getPropertyOf(json, "quantity")->Option.flatMap(s => getProperty(s, "type")),
|
|
1677
|
+
)->toEqual(Some(JSON.Encode.string("integer"))),
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
testSync(
|
|
1681
|
+
"carries a string default the same way",
|
|
1682
|
+
() =>
|
|
1683
|
+
expect(defaultOf(json, ~field="shippingMethod"))->toEqual(
|
|
1684
|
+
Some(JSON.Encode.string("Standard")),
|
|
1685
|
+
),
|
|
1686
|
+
)
|
|
1687
|
+
|
|
1688
|
+
testSync(
|
|
1689
|
+
"while a field declaring none says nothing",
|
|
1690
|
+
() => expect(defaultOf(json, ~field="plain"))->toBe(None),
|
|
1691
|
+
)
|
|
1692
|
+
|
|
1693
|
+
// The case the whole marker exists for: a name-keyed annotation spec speaks
|
|
1694
|
+
// for a record's own fields, and an order's line items are one level in.
|
|
1695
|
+
testSync(
|
|
1696
|
+
"and reaches a field of a nested record",
|
|
1697
|
+
() => {
|
|
1698
|
+
let lineItem = S.schema(
|
|
1699
|
+
s =>
|
|
1700
|
+
{
|
|
1701
|
+
"productId": s.matches(S.string),
|
|
1702
|
+
"quantity": s.matches(S.int->Reventless.FieldDefault.int(1)),
|
|
1703
|
+
},
|
|
1704
|
+
)
|
|
1705
|
+
let nested = SuryToJsonSchema.deriveObjectSchema(
|
|
1706
|
+
S.schema(s => {"lineItems": s.matches(S.array(lineItem))})->S.castToUnknown,
|
|
1707
|
+
)
|
|
1708
|
+
expect(
|
|
1709
|
+
getPropertyOf(nested, "lineItems")
|
|
1710
|
+
->Option.flatMap(a => getProperty(a, "items"))
|
|
1711
|
+
->Option.flatMap(i => defaultOf(i, ~field="quantity")),
|
|
1712
|
+
)->toEqual(Some(JSON.Encode.int(1)))
|
|
1713
|
+
},
|
|
1714
|
+
)
|
|
1715
|
+
})
|
|
1359
1716
|
})
|