Skip to content

Commit 4724db7

Browse files
committed
feat: add x-algokit-byte-length vendor extension support for fixed-length byte validation
- Add byte_length and list_inner_byte_length fields to TypeInfo in builder.py - Update _build_metadata() to generate fixed-length byte serde helpers - Add encode_fixed_bytes_base64/decode_fixed_bytes_base64 helpers for 32/64 byte validation - Add encode_fixed_bytes_sequence/decode_fixed_bytes_sequence for array validation - Regenerate API clients with fixed-length byte fields (group, lease, signature, etc.) - Update OAS_BRANCH to fix/byte-len-validation for spec generation
1 parent 08a8459 commit 4724db7

File tree

18 files changed

+314
-71
lines changed

18 files changed

+314
-71
lines changed

api/oas-generator/src/oas_generator/builder.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ class TypeInfo:
3333
is_locals_reference: bool = False
3434
is_holding_reference: bool = False
3535
needs_datetime: bool = False
36+
byte_length: int | None = None # Fixed byte length from x-algokit-byte-length
37+
list_inner_byte_length: int | None = None # Fixed byte length for list items
3638
imports: set[str] = field(default_factory=set)
3739

3840

@@ -170,7 +172,10 @@ def resolve(self, schema: ctx.RawSchema, *, hint: str = "Inline") -> TypeInfo:
170172
if schema_type == "string":
171173
fmt = schema.get("format")
172174
if fmt in {"byte", "binary"} or schema.get("x-algokit-bytes-base64"):
173-
info = TypeInfo(annotation="bytes", is_bytes=True)
175+
# Extract fixed byte length if present
176+
byte_length_val = schema.get("x-algokit-byte-length")
177+
byte_length = int(byte_length_val) if byte_length_val is not None else None
178+
info = TypeInfo(annotation="bytes", is_bytes=True, byte_length=byte_length)
174179
elif fmt == "date-time":
175180
info = TypeInfo(
176181
annotation="datetime",
@@ -219,6 +224,7 @@ def _resolve_array(self, schema: ctx.RawSchema, *, hint: str) -> TypeInfo:
219224
list_inner_model=inner.model,
220225
list_inner_enum=inner.enum,
221226
list_inner_is_bytes=inner.is_bytes,
227+
list_inner_byte_length=inner.byte_length,
222228
is_signed_transaction=inner.is_signed_transaction,
223229
is_box_reference=inner.is_box_reference,
224230
is_locals_reference=inner.is_locals_reference,
@@ -462,6 +468,10 @@ def _build_model(self, entry: SchemaEntry) -> ctx.ModelDescriptor: # noqa: C901
462468
imports.add("from ._serde_helpers import decode_bytes_base64, encode_bytes_base64")
463469
if "encode_bytes_sequence" in field.metadata or "decode_bytes_sequence" in field.metadata:
464470
imports.add("from ._serde_helpers import decode_bytes_sequence, encode_bytes_sequence")
471+
if "encode_fixed_bytes_base64" in field.metadata or "decode_fixed_bytes_base64" in field.metadata:
472+
imports.add("from ._serde_helpers import decode_fixed_bytes_base64, encode_fixed_bytes_base64")
473+
if "encode_fixed_bytes_sequence" in field.metadata or "decode_fixed_bytes_sequence" in field.metadata:
474+
imports.add("from ._serde_helpers import decode_fixed_bytes_sequence, encode_fixed_bytes_sequence")
465475
if "nested(" in field.metadata:
466476
uses_nested = True
467477
if "flatten(" in field.metadata:
@@ -585,6 +595,15 @@ def _build_metadata(self, wire_name: str, type_info: TypeInfo, *, required: bool
585595
" )"
586596
)
587597
if type_info.is_list and type_info.list_inner_is_bytes:
598+
# Handle fixed-length bytes in sequences
599+
if type_info.list_inner_byte_length is not None:
600+
return (
601+
"wire(\n"
602+
f' "{alias}",\n'
603+
f" encode=lambda v: encode_fixed_bytes_sequence(v, {type_info.list_inner_byte_length}),\n"
604+
f" decode=lambda raw: decode_fixed_bytes_sequence(raw, {type_info.list_inner_byte_length}),\n"
605+
" )"
606+
)
588607
return (
589608
"wire(\n"
590609
f' "{alias}",\n'
@@ -593,6 +612,15 @@ def _build_metadata(self, wire_name: str, type_info: TypeInfo, *, required: bool
593612
" )"
594613
)
595614
if type_info.is_bytes:
615+
# Handle fixed-length bytes
616+
if type_info.byte_length is not None:
617+
return (
618+
"wire(\n"
619+
f' "{alias}",\n'
620+
f" encode=lambda v: encode_fixed_bytes_base64(v, {type_info.byte_length}),\n"
621+
f" decode=lambda raw: decode_fixed_bytes_base64(raw, {type_info.byte_length}),\n"
622+
" )"
623+
)
596624
return (
597625
"wire(\n"
598626
f' "{alias}",\n'

api/oas-generator/src/oas_generator/renderer/templates/models/_serde_helpers.py.j2

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ def decode_bytes_base64(raw: object) -> bytes:
3737
raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}")
3838

3939

40+
def encode_fixed_bytes_base64(value: BytesLike, expected_length: int) -> str:
41+
"""Encode fixed-length bytes to base64, validating the length."""
42+
coerced = _coerce_bytes(value)
43+
if len(coerced) != expected_length:
44+
raise ValueError(f"Expected {expected_length} bytes, got {len(coerced)}")
45+
return base64.b64encode(coerced).decode("ascii")
46+
47+
48+
def decode_fixed_bytes_base64(raw: object, expected_length: int) -> bytes:
49+
"""Decode base64 to fixed-length bytes, validating the length."""
50+
decoded = decode_bytes_base64(raw)
51+
if len(decoded) != expected_length:
52+
raise ValueError(f"Expected {expected_length} bytes, got {len(decoded)}")
53+
return decoded
54+
55+
4056
def decode_bytes_map_key(raw: object) -> bytes:
4157
if isinstance(raw, bytes | bytearray | memoryview):
4258
return bytes(raw)
@@ -77,6 +93,36 @@ def decode_bytes_sequence(raw: object) -> list[bytes | None] | None:
7793
return decoded or None
7894

7995

96+
def encode_fixed_bytes_sequence(
97+
values: Iterable[BytesLike | None] | None, expected_length: int
98+
) -> list[str | None] | None:
99+
"""Encode a sequence of fixed-length bytes to base64, validating each element's length."""
100+
if values is None:
101+
return None
102+
encoded: list[str | None] = []
103+
for value in values:
104+
if value is None:
105+
encoded.append(None)
106+
continue
107+
if not isinstance(value, bytes | bytearray | memoryview):
108+
raise TypeError(f"Unsupported value for bytes field sequence: {type(value)!r}")
109+
encoded.append(encode_fixed_bytes_base64(value, expected_length))
110+
return encoded or None
111+
112+
113+
def decode_fixed_bytes_sequence(raw: object, expected_length: int) -> list[bytes | None] | None:
114+
"""Decode a sequence of base64 strings to fixed-length bytes, validating each element's length."""
115+
if not isinstance(raw, list):
116+
return None
117+
decoded: list[bytes | None] = []
118+
for item in raw:
119+
if item is None:
120+
decoded.append(None)
121+
continue
122+
decoded.append(decode_fixed_bytes_base64(item, expected_length))
123+
return decoded or None
124+
125+
80126
def encode_model_sequence(values: Iterable[object] | None) -> list[dict[str, object]] | None:
81127
if values is None:
82128
return None

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ sequence = [
239239
{ cmd = "ruff check --fix src/algokit_${SPEC}_client" },
240240
{ cmd = "ruff format src/algokit_${SPEC}_client" },
241241
]
242-
env.OAS_BRANCH = "main"
242+
env.OAS_BRANCH = "fix/byte-len-validation"
243243

244244
[tool.poe.tasks.generate-algod-client]
245245
ref = "generate-client"

src/algokit_algod_client/models/_account_participation.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from algokit_common.serde import wire
77

8-
from ._serde_helpers import decode_bytes_base64, encode_bytes_base64
8+
from ._serde_helpers import decode_fixed_bytes_base64, encode_fixed_bytes_base64
99

1010

1111
@dataclass(slots=True)
@@ -19,8 +19,8 @@ class AccountParticipation:
1919
default=b"",
2020
metadata=wire(
2121
"selection-participation-key",
22-
encode=encode_bytes_base64,
23-
decode=decode_bytes_base64,
22+
encode=lambda v: encode_fixed_bytes_base64(v, 32),
23+
decode=lambda raw: decode_fixed_bytes_base64(raw, 32),
2424
),
2525
)
2626
vote_first_valid: int = field(
@@ -39,15 +39,15 @@ class AccountParticipation:
3939
default=b"",
4040
metadata=wire(
4141
"vote-participation-key",
42-
encode=encode_bytes_base64,
43-
decode=decode_bytes_base64,
42+
encode=lambda v: encode_fixed_bytes_base64(v, 32),
43+
decode=lambda raw: decode_fixed_bytes_base64(raw, 32),
4444
),
4545
)
4646
state_proof_key: bytes | None = field(
4747
default=None,
4848
metadata=wire(
4949
"state-proof-key",
50-
encode=encode_bytes_base64,
51-
decode=decode_bytes_base64,
50+
encode=lambda v: encode_fixed_bytes_base64(v, 64),
51+
decode=lambda raw: decode_fixed_bytes_base64(raw, 64),
5252
),
5353
)

src/algokit_algod_client/models/_asset_params.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55

66
from algokit_common.serde import wire
77

8-
from ._serde_helpers import decode_bytes_base64, encode_bytes_base64
8+
from ._serde_helpers import (
9+
decode_bytes_base64,
10+
decode_fixed_bytes_base64,
11+
encode_bytes_base64,
12+
encode_fixed_bytes_base64,
13+
)
914

1015

1116
@dataclass(slots=True)
@@ -51,8 +56,8 @@ class AssetParams:
5156
default=None,
5257
metadata=wire(
5358
"metadata-hash",
54-
encode=encode_bytes_base64,
55-
decode=decode_bytes_base64,
59+
encode=lambda v: encode_fixed_bytes_base64(v, 32),
60+
decode=lambda raw: decode_fixed_bytes_base64(raw, 32),
5661
),
5762
)
5863
name: str | None = field(

src/algokit_algod_client/models/_serde_helpers.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ def decode_bytes_base64(raw: object) -> bytes:
3737
raise TypeError(f"Unsupported value for bytes field: {type(raw)!r}")
3838

3939

40+
def encode_fixed_bytes_base64(value: BytesLike, expected_length: int) -> str:
41+
"""Encode fixed-length bytes to base64, validating the length."""
42+
coerced = _coerce_bytes(value)
43+
if len(coerced) != expected_length:
44+
raise ValueError(f"Expected {expected_length} bytes, got {len(coerced)}")
45+
return base64.b64encode(coerced).decode("ascii")
46+
47+
48+
def decode_fixed_bytes_base64(raw: object, expected_length: int) -> bytes:
49+
"""Decode base64 to fixed-length bytes, validating the length."""
50+
decoded = decode_bytes_base64(raw)
51+
if len(decoded) != expected_length:
52+
raise ValueError(f"Expected {expected_length} bytes, got {len(decoded)}")
53+
return decoded
54+
55+
4056
def decode_bytes_map_key(raw: object) -> bytes:
4157
if isinstance(raw, bytes | bytearray | memoryview):
4258
return bytes(raw)
@@ -77,6 +93,36 @@ def decode_bytes_sequence(raw: object) -> list[bytes | None] | None:
7793
return decoded or None
7894

7995

96+
def encode_fixed_bytes_sequence(
97+
values: Iterable[BytesLike | None] | None, expected_length: int
98+
) -> list[str | None] | None:
99+
"""Encode a sequence of fixed-length bytes to base64, validating each element's length."""
100+
if values is None:
101+
return None
102+
encoded: list[str | None] = []
103+
for value in values:
104+
if value is None:
105+
encoded.append(None)
106+
continue
107+
if not isinstance(value, bytes | bytearray | memoryview):
108+
raise TypeError(f"Unsupported value for bytes field sequence: {type(value)!r}")
109+
encoded.append(encode_fixed_bytes_base64(value, expected_length))
110+
return encoded or None
111+
112+
113+
def decode_fixed_bytes_sequence(raw: object, expected_length: int) -> list[bytes | None] | None:
114+
"""Decode a sequence of base64 strings to fixed-length bytes, validating each element's length."""
115+
if not isinstance(raw, list):
116+
return None
117+
decoded: list[bytes | None] = []
118+
for item in raw:
119+
if item is None:
120+
decoded.append(None)
121+
continue
122+
decoded.append(decode_fixed_bytes_base64(item, expected_length))
123+
return decoded or None
124+
125+
80126
def encode_model_sequence(values: Iterable[object] | None) -> list[dict[str, object]] | None:
81127
if values is None:
82128
return None

src/algokit_algod_client/models/_transaction_parameters_response.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from algokit_common.serde import wire
77

8-
from ._serde_helpers import decode_bytes_base64, encode_bytes_base64
8+
from ._serde_helpers import decode_fixed_bytes_base64, encode_fixed_bytes_base64
99

1010

1111
@dataclass(slots=True)
@@ -27,8 +27,8 @@ class TransactionParametersResponse:
2727
default=b"",
2828
metadata=wire(
2929
"genesis-hash",
30-
encode=encode_bytes_base64,
31-
decode=decode_bytes_base64,
30+
encode=lambda v: encode_fixed_bytes_base64(v, 32),
31+
decode=lambda raw: decode_fixed_bytes_base64(raw, 32),
3232
),
3333
)
3434
genesis_id: str = field(

src/algokit_indexer_client/models/_account_participation.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from algokit_common.serde import wire
77

8-
from ._serde_helpers import decode_bytes_base64, encode_bytes_base64
8+
from ._serde_helpers import decode_fixed_bytes_base64, encode_fixed_bytes_base64
99

1010

1111
@dataclass(slots=True)
@@ -19,8 +19,8 @@ class AccountParticipation:
1919
default=b"",
2020
metadata=wire(
2121
"selection-participation-key",
22-
encode=encode_bytes_base64,
23-
decode=decode_bytes_base64,
22+
encode=lambda v: encode_fixed_bytes_base64(v, 32),
23+
decode=lambda raw: decode_fixed_bytes_base64(raw, 32),
2424
),
2525
)
2626
vote_first_valid: int = field(
@@ -39,15 +39,15 @@ class AccountParticipation:
3939
default=b"",
4040
metadata=wire(
4141
"vote-participation-key",
42-
encode=encode_bytes_base64,
43-
decode=decode_bytes_base64,
42+
encode=lambda v: encode_fixed_bytes_base64(v, 32),
43+
decode=lambda raw: decode_fixed_bytes_base64(raw, 32),
4444
),
4545
)
4646
state_proof_key: bytes | None = field(
4747
default=None,
4848
metadata=wire(
4949
"state-proof-key",
50-
encode=encode_bytes_base64,
51-
decode=decode_bytes_base64,
50+
encode=lambda v: encode_fixed_bytes_base64(v, 64),
51+
decode=lambda raw: decode_fixed_bytes_base64(raw, 64),
5252
),
5353
)

src/algokit_indexer_client/models/_asset_params.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55

66
from algokit_common.serde import wire
77

8-
from ._serde_helpers import decode_bytes_base64, encode_bytes_base64
8+
from ._serde_helpers import (
9+
decode_bytes_base64,
10+
decode_fixed_bytes_base64,
11+
encode_bytes_base64,
12+
encode_fixed_bytes_base64,
13+
)
914

1015

1116
@dataclass(slots=True)
@@ -51,8 +56,8 @@ class AssetParams:
5156
default=None,
5257
metadata=wire(
5358
"metadata-hash",
54-
encode=encode_bytes_base64,
55-
decode=decode_bytes_base64,
59+
encode=lambda v: encode_fixed_bytes_base64(v, 32),
60+
decode=lambda raw: decode_fixed_bytes_base64(raw, 32),
5661
),
5762
)
5863
name: str | None = field(

0 commit comments

Comments
 (0)