Skip to content

Commit cc88390

Browse files
bieluclaude
andcommitted
refactor: model Solace integer-or-schema values as a type hierarchy
SolaceIntegerOrSchema is now an abstract base with SolaceInteger and SolaceIntegerSchema subtypes, matching the MQTT union types. Callers use type checks instead of null-checking Integer and Schema, and an empty value can no longer be constructed, so Validate() is gone. Implicit conversions from long and AsyncApiJsonSchema keep assignments such as Priority = 7 compiling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 70e73db commit cc88390

3 files changed

Lines changed: 79 additions & 106 deletions

File tree

‎src/ByteBard.AsyncAPI.Bindings/Solace/SolaceIntegerOrSchema.cs‎

Lines changed: 59 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,81 +3,85 @@ namespace ByteBard.AsyncAPI.Bindings.Solace
33
using System;
44
using ByteBard.AsyncAPI.Models;
55
using ByteBard.AsyncAPI.Models.Interfaces;
6+
using ByteBard.AsyncAPI.Readers;
7+
using ByteBard.AsyncAPI.Readers.ParseNodes;
68
using ByteBard.AsyncAPI.Writers;
79

8-
/// <summary>Represents a Solace integer, JSON Schema object, or JSON Schema reference.</summary>
9-
public class SolaceIntegerOrSchema : IAsyncApiSerializable
10+
/// <summary>
11+
/// A Solace value represented by either an integer (<see cref="SolaceInteger"/>) or a JSON Schema or schema reference (<see cref="SolaceIntegerSchema"/>).
12+
/// </summary>
13+
/// <remarks>Integers and schemas convert implicitly, so <c>binding.Priority = 7;</c> works.</remarks>
14+
public abstract class SolaceIntegerOrSchema : IAsyncApiSerializable
1015
{
11-
private long? integer;
12-
private AsyncApiJsonSchema schema;
13-
14-
public long? Integer
16+
private protected SolaceIntegerOrSchema()
1517
{
16-
get => this.integer;
17-
set
18-
{
19-
this.integer = value;
20-
if (value.HasValue) this.schema = null;
21-
}
2218
}
2319

24-
public AsyncApiJsonSchema Schema
25-
{
26-
get => this.schema;
27-
set
28-
{
29-
this.schema = value;
30-
if (value != null) this.integer = null;
31-
}
32-
}
20+
public static implicit operator SolaceIntegerOrSchema(long value) => new SolaceInteger(value);
21+
22+
public static implicit operator SolaceIntegerOrSchema(AsyncApiJsonSchema schema) => schema == null ? null : new SolaceIntegerSchema(schema);
3323

3424
/// <summary>
35-
/// Writes the integer or schema value directly for AsyncAPI 2.0, after validating that a value is present.
25+
/// Parses a scalar as a signed 64-bit integer, or an object as a schema or schema reference.
3626
/// </summary>
37-
/// <exception cref="ArgumentNullException"><paramref name="writer"/> is null.</exception>
38-
/// <exception cref="InvalidOperationException">Neither an integer nor a schema is set.</exception>
39-
public void SerializeV2(IAsyncApiWriter writer) => this.Serialize(writer, false);
27+
/// <returns>A <see cref="SolaceInteger"/> or <see cref="SolaceIntegerSchema"/>.</returns>
28+
/// <remarks>Schema field errors handled by the reader are recorded as diagnostics. Scalar parsing errors propagate to the caller.</remarks>
29+
/// <exception cref="ByteBard.AsyncAPI.Readers.Exceptions.AsyncApiReaderException">The scalar cannot be parsed as an Int64, or a non-scalar node is not a schema object.</exception>
30+
/// <exception cref="ByteBard.AsyncAPI.Exceptions.AsyncApiException">A schema reference has a non-scalar $ref value.</exception>
31+
public static SolaceIntegerOrSchema Parse(ParseNode node)
32+
{
33+
return node is ValueNode
34+
? new SolaceInteger(node.GetLongValue())
35+
: new SolaceIntegerSchema(AsyncApiJsonSchemaDeserializer.LoadSchema(node));
36+
}
37+
4038
/// <summary>
41-
/// Writes the integer or schema value directly for AsyncAPI 3.0, after validating that a value is present.
39+
/// Writes the integer or schema value directly for AsyncAPI 2.0, without a property name or wrapper object.
4240
/// </summary>
43-
/// <exception cref="ArgumentNullException"><paramref name="writer"/> is null.</exception>
44-
/// <exception cref="InvalidOperationException">Neither an integer nor a schema is set.</exception>
45-
public void SerializeV3(IAsyncApiWriter writer) => this.Serialize(writer, true);
41+
public abstract void SerializeV2(IAsyncApiWriter writer);
4642

4743
/// <summary>
48-
/// Checks that an integer or schema is present, without validating its range or schema contents.
44+
/// Writes the integer or schema value directly for AsyncAPI 3.0, without a property name or wrapper object.
4945
/// </summary>
50-
/// <exception cref="InvalidOperationException">Neither an integer nor a schema is set.</exception>
51-
public void Validate()
46+
public abstract void SerializeV3(IAsyncApiWriter writer);
47+
}
48+
49+
/// <summary>The integer alternative of <see cref="SolaceIntegerOrSchema"/>.</summary>
50+
public sealed class SolaceInteger : SolaceIntegerOrSchema
51+
{
52+
public SolaceInteger(long value)
5253
{
53-
if (!this.Integer.HasValue && this.Schema == null)
54-
{
55-
throw new InvalidOperationException("A Solace integer-or-schema value must contain an integer or a schema.");
56-
}
54+
this.Value = value;
5755
}
5856

57+
public long Value { get; }
58+
59+
/// <exception cref="ArgumentNullException"><paramref name="value"/> is null.</exception>
60+
public static implicit operator long(SolaceInteger value) => (value ?? throw new ArgumentNullException(nameof(value))).Value;
61+
62+
public override void SerializeV2(IAsyncApiWriter writer) => writer.WriteValue(this.Value);
63+
64+
public override void SerializeV3(IAsyncApiWriter writer) => writer.WriteValue(this.Value);
65+
}
66+
67+
/// <summary>The schema alternative of <see cref="SolaceIntegerOrSchema"/>.</summary>
68+
public sealed class SolaceIntegerSchema : SolaceIntegerOrSchema
69+
{
5970
/// <summary>
60-
/// Validates and writes the integer or schema value without a property name or wrapper object.
71+
/// Retains the supplied schema or reference without copying it.
6172
/// </summary>
62-
/// <param name="writer">The writer receiving the value.</param>
63-
/// <param name="v3">Uses AsyncAPI 3.0 schema serialization when true and AsyncAPI 2.0 when false.</param>
64-
/// <exception cref="ArgumentNullException"><paramref name="writer"/> is null.</exception>
65-
/// <exception cref="InvalidOperationException">Neither an integer nor a schema is set.</exception>
66-
private void Serialize(IAsyncApiWriter writer, bool v3)
73+
/// <exception cref="ArgumentNullException"><paramref name="schema"/> is null.</exception>
74+
public SolaceIntegerSchema(AsyncApiJsonSchema schema)
6775
{
68-
if (writer is null) throw new ArgumentNullException(nameof(writer));
69-
this.Validate();
70-
if (this.Integer.HasValue)
71-
{
72-
writer.WriteValue(this.Integer.Value);
73-
return;
74-
}
75-
76-
if (this.Schema != null)
77-
{
78-
if (v3) this.Schema.SerializeV3(writer);
79-
else this.Schema.SerializeV2(writer);
80-
}
76+
this.Schema = schema ?? throw new ArgumentNullException(nameof(schema));
8177
}
78+
79+
public AsyncApiJsonSchema Schema { get; }
80+
81+
public static implicit operator AsyncApiJsonSchema(SolaceIntegerSchema value) => value?.Schema;
82+
83+
public override void SerializeV2(IAsyncApiWriter writer) => this.Schema.SerializeV2(writer);
84+
85+
public override void SerializeV3(IAsyncApiWriter writer) => this.Schema.SerializeV3(writer);
8286
}
8387
}

‎src/ByteBard.AsyncAPI.Bindings/Solace/SolaceOperationBinding.cs‎

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,61 +20,38 @@ public class SolaceOperationBinding : OperationBinding<SolaceOperationBinding>
2020
{
2121
{ "bindingVersion", (a, n) => a.BindingVersion = n.GetScalarValue() },
2222
{ "destinations", (a, n) => a.Destinations = n.CreateList(s => s.ParseMap(SolaceDestination.Fields)) },
23-
{ "timeToLive", (a, n) => a.TimeToLive = ReadIntegerOrSchema(n) },
24-
{ "priority", (a, n) => a.Priority = ReadIntegerOrSchema(n) },
23+
{ "timeToLive", (a, n) => a.TimeToLive = SolaceIntegerOrSchema.Parse(n) },
24+
{ "priority", (a, n) => a.Priority = SolaceIntegerOrSchema.Parse(n) },
2525
{ "dmqEligible", (a, n) => a.DmqEligible = n.GetBooleanValue() },
2626
};
2727

28-
/// <summary>
29-
/// Parses a scalar as a signed 64-bit integer, or an object as a schema or schema reference.
30-
/// </summary>
31-
/// <returns>A value containing the parsed integer or schema.</returns>
32-
/// <remarks>Schema field errors handled by the reader are recorded as diagnostics. Scalar parsing errors propagate to the caller.</remarks>
33-
/// <exception cref="ByteBard.AsyncAPI.Readers.Exceptions.AsyncApiReaderException">The scalar cannot be parsed as an Int64, or a non-scalar node is not a schema object.</exception>
34-
/// <exception cref="ByteBard.AsyncAPI.Exceptions.AsyncApiException">A schema reference has a non-scalar $ref value.</exception>
35-
internal static SolaceIntegerOrSchema ReadIntegerOrSchema(ParseNode node)
36-
{
37-
if (node is ValueNode)
38-
{
39-
return new SolaceIntegerOrSchema { Integer = node.GetLongValue() };
40-
}
41-
42-
return new SolaceIntegerOrSchema { Schema = AsyncApiJsonSchemaDeserializer.LoadSchema(node) };
43-
}
44-
4528
/// <summary>
4629
/// Writes the Solace operation binding object and its extensions for AsyncAPI 2.0.
4730
/// </summary>
4831
/// <exception cref="ArgumentNullException"><paramref name="writer"/> is null.</exception>
49-
/// <exception cref="InvalidOperationException">TimeToLive or Priority is present but contains neither an integer nor a schema.</exception>
5032
public override void SerializeV2(IAsyncApiWriter writer) => Serialize(writer, false);
5133
/// <summary>
5234
/// Writes the Solace operation binding object and its extensions for AsyncAPI 3.0.
5335
/// </summary>
5436
/// <exception cref="ArgumentNullException"><paramref name="writer"/> is null.</exception>
55-
/// <exception cref="InvalidOperationException">TimeToLive or Priority is present but contains neither an integer nor a schema.</exception>
5637
public override void SerializeV3(IAsyncApiWriter writer) => Serialize(writer, true);
5738

5839
/// <summary>
5940
/// Writes the Solace operation binding object and its extensions for AsyncAPI 2.0.
6041
/// </summary>
61-
/// <remarks>Validates the optional integer-or-schema values before writing the binding. Explicit false DmqEligible values are preserved.</remarks>
42+
/// <remarks>Explicit false DmqEligible values are preserved.</remarks>
6243
/// <exception cref="ArgumentNullException"><paramref name="writer"/> is null.</exception>
63-
/// <exception cref="InvalidOperationException">TimeToLive or Priority is present but contains neither an integer nor a schema.</exception>
6444
public override void SerializeProperties(IAsyncApiWriter writer) => Serialize(writer, false);
6545

6646
/// <summary>
67-
/// Validates the optional integer-or-schema values, then writes the binding object and its extensions.
47+
/// Writes the binding object and its extensions.
6848
/// </summary>
6949
/// <param name="writer">The writer receiving the value.</param>
7050
/// <param name="v3">Uses AsyncAPI 3.0 schema serialization when true and AsyncAPI 2.0 when false.</param>
7151
/// <exception cref="ArgumentNullException"><paramref name="writer"/> is null.</exception>
72-
/// <exception cref="InvalidOperationException">TimeToLive or Priority is present but contains neither an integer nor a schema.</exception>
7352
private void Serialize(IAsyncApiWriter writer, bool v3)
7453
{
7554
if (writer is null) throw new ArgumentNullException(nameof(writer));
76-
this.TimeToLive?.Validate();
77-
this.Priority?.Validate();
7855
writer.WriteStartObject();
7956
writer.WriteOptionalCollection("destinations", this.Destinations, (w, d) => d.Serialize(w));
8057
writer.WriteOptionalObject("timeToLive", this.TimeToLive, (w, value) => { if (v3) value.SerializeV3(w); else value.SerializeV2(w); });

‎test/ByteBard.AsyncAPI.Tests/Bindings/Solace/SolaceBindings_Should.cs‎

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ public void OperationBinding_RoundTripsNestedDestinationsAndScalarUnionValues(As
2020
operation.Bindings.Add(new SolaceOperationBinding
2121
{
2222
DmqEligible = false,
23-
TimeToLive = new SolaceIntegerOrSchema { Integer = 0 },
24-
Priority = new SolaceIntegerOrSchema { Integer = 7 },
23+
TimeToLive = 0,
24+
Priority = 7,
2525
Destinations =
2626
{
2727
new SolaceDestination
@@ -60,14 +60,8 @@ public void OperationBinding_RoundTripsSchemaAndReferenceUnionValues(AsyncApiVer
6060
};
6161
operation.Bindings.Add(new SolaceOperationBinding
6262
{
63-
TimeToLive = new SolaceIntegerOrSchema
64-
{
65-
Schema = new AsyncApiJsonSchema { Description = "ttl schema" },
66-
},
67-
Priority = new SolaceIntegerOrSchema
68-
{
69-
Schema = new AsyncApiJsonSchemaReference("#/components/schemas/Priority"),
70-
},
63+
TimeToLive = new AsyncApiJsonSchema { Description = "ttl schema" },
64+
Priority = new AsyncApiJsonSchemaReference("#/components/schemas/Priority"),
7165
});
7266

7367
var document = new AsyncApiDocument
@@ -96,8 +90,8 @@ public void OperationBinding_RoundTripsSchemaAndReferenceUnionValues(AsyncApiVer
9690
diagnostics.Errors.Should().BeEmpty();
9791
var binding = reparsed.Operations.Values.Should().ContainSingle().Which.Bindings.Values.Should()
9892
.ContainSingle().Which.Should().BeOfType<SolaceOperationBinding>().Subject;
99-
binding.TimeToLive.Schema.Description.Should().Be("ttl schema");
100-
((AsyncApiJsonSchemaReference)binding.Priority.Schema).Reference.Reference.Should().Be("#/components/schemas/Priority");
93+
binding.TimeToLive.Should().BeOfType<SolaceIntegerSchema>().Which.Schema.Description.Should().Be("ttl schema");
94+
binding.Priority.Should().BeOfType<SolaceIntegerSchema>().Which.Schema.Should().BeOfType<AsyncApiJsonSchemaReference>().Which.Reference.Reference.Should().Be("#/components/schemas/Priority");
10195
}
10296

10397
[TestCase(AsyncApiVersion.AsyncApi2_0)]
@@ -108,7 +102,7 @@ public void OperationBinding_RoundTripsLargeTimeToLive(AsyncApiVersion version)
108102
var operation = new AsyncApiOperation();
109103
operation.Bindings.Add(new SolaceOperationBinding
110104
{
111-
TimeToLive = new SolaceIntegerOrSchema { Integer = largeTtl },
105+
TimeToLive = largeTtl,
112106
});
113107

114108
var yaml = operation.SerializeAsYaml(version);
@@ -118,22 +112,20 @@ public void OperationBinding_RoundTripsLargeTimeToLive(AsyncApiVersion version)
118112
settings.Bindings = new List<IBindingParser<IBinding>> { new SolaceOperationBinding() };
119113
var parsed = new AsyncApiStringReader(settings).ReadFragment<AsyncApiOperation>(yaml, version, out _);
120114
var binding = parsed.Bindings.Should().ContainSingle().Which.Value.Should().BeOfType<SolaceOperationBinding>().Subject;
121-
binding.TimeToLive.Integer.Should().Be(largeTtl);
115+
binding.TimeToLive.Should().BeOfType<SolaceInteger>().Which.Value.Should().Be(largeTtl);
122116
}
123117

124118
[Test]
125-
public void OperationBinding_RejectsEmptyIntegerOrSchemaBeforeWriting()
119+
public void IntegerOrSchema_ConvertsImplicitlyToAndFromItsAlternatives()
126120
{
127-
var operation = new AsyncApiOperation();
128-
operation.Bindings.Add(new SolaceOperationBinding
129-
{
130-
TimeToLive = new SolaceIntegerOrSchema(),
131-
});
121+
var schema = new AsyncApiJsonSchema { Type = SchemaType.Integer };
122+
var binding = new SolaceOperationBinding { TimeToLive = 2592000000L, Priority = schema };
123+
124+
long timeToLive = binding.TimeToLive.Should().BeOfType<SolaceInteger>().Subject;
125+
AsyncApiJsonSchema priority = binding.Priority.Should().BeOfType<SolaceIntegerSchema>().Subject;
132126

133-
var exception = FluentActions.Invoking(() => operation.SerializeAsYaml(AsyncApiVersion.AsyncApi2_0))
134-
.Should().Throw<System.InvalidOperationException>()
135-
.Which;
136-
exception.Message.Should().Contain("must contain an integer or a schema");
127+
timeToLive.Should().Be(2592000000L);
128+
priority.Should().BeSameAs(schema);
137129
}
138130

139131
[TestCase(AsyncApiVersion.AsyncApi2_0)]

0 commit comments

Comments
 (0)