Codecs¶
headgate's core imports no serialization library. A codec is the one interface between them: how a library reads bytes into a type and writes a value back. headgate ships two, and any other library plugs in the same way.
The codecs headgate ships¶
| Codec | Reads into | Install |
|---|---|---|
headgate.msgspec |
msgspec.Struct and any msgspec type |
pip install 'headgate[msgspec]' |
headgate.pydantic |
pydantic.BaseModel and any pydantic type |
pip install 'headgate[pydantic]' |
pip install headgate alone brings neither. Each module has the same shortcuts, so switching is changing one import:
from headgate.msgspec import decode, decoder, encoder, to_builtins
ORDER = decoder(Order) # a reader, for expect=
NEW_ORDER = encoder(NewOrder) # a writer, for body=
Both decode strictly (a field that changes type on the wire fails rather than being coerced), ignore fields the type
does not declare, and describe a failure without repeating the body. Each module's CODEC is the codec itself, for
the functions below.
Any codec, the same way¶
Three functions work on any codec, the two above included:
decoder_for(codec, Type, *, allow_empty_body=False, refusal_check=None)builds a reader forexpect=;encoder_for(codec, Type)builds a writer forbody=, sent under the codec's media type;decode_with(codec, content, Type)decodes bytes that are not a whole response, such as a document nested in another.
A body the codec cannot read raises DecodeError, which a call raises as ResponseParseError with the codec's
description of what was wrong. A library's own validation error never escapes the ClientError tree.
Bringing your own library¶
A codec is any object with five members, the Codec protocol:
| Member | What it is |
|---|---|
media_type |
what its bytes are: the Content-Type a writer sends |
errors |
the exceptions it raises for malformed or mistyped bytes, turned into DecodeError |
decoder(Type) |
a function reading bytes into Type, built once per type |
encoder(Type) |
a function writing a value of Type as bytes, built once per type |
describe(error) |
a message for one of errors that never quotes the input, which may hold a credential |
cattrs over orjson, for example:
import cattrs
import orjson
from headgate import decoder_for, encoder_for
converter = cattrs.Converter(forbid_extra_keys=False)
class CattrsJson:
"""attrs classes and dataclasses, structured by cattrs, as JSON through orjson."""
media_type = "application/json"
errors = (orjson.JSONDecodeError, cattrs.BaseValidationError)
def decoder(self, type_):
return lambda content: converter.structure(orjson.loads(content), type_)
def encoder(self, type_):
return lambda value: orjson.dumps(converter.unstructure(value, type_))
def describe(self, error):
# The exception's type names the problem; describing only that keeps every value out of the message.
return type(error).__name__
CODEC = CattrsJson()
ORDER = decoder_for(CODEC, Order)
NEW_ORDER = encoder_for(CODEC, NewOrder)
A format other than JSON is the same five members with another media_type: a msgpack codec says
"application/msgpack", and its writers send that. To read only that type back, declare it on the call's Expect:
Expect(ORDER, accept="application/msgpack").
The promises, checked¶
headgate.testing.check_codec holds a codec to the promises the built-in ones keep, so a codec of your own behaves
like them. Call it from your tests, with a record type of the codec's own kind holding one field, value: int:
from headgate.testing import check_codec
def test_the_codec_keeps_headgates_promises() -> None:
check_codec(CODEC, record=Row)
It raises AssertionError naming the first promise broken:
media_typeis a media type;- a value it writes reads back equal;
- a string where an
intis declared is refused, not coerced; - a cut-short body is refused;
- each refusal raises one of
errors, and its description never repeats the input; - a field the record does not declare is ignored, since providers add fields without notice.
Every body it reads is one the codec wrote itself, so the check works for any format.