Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions docs-main/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,47 @@ body:has(.x2mdx-ref-page--operation) [aria-label="Table of contents"] {
overflow-wrap: anywhere;
}

.x2mdx-ref-schema-variants {
display: grid;
gap: 0.8rem;
margin-top: 0.9rem;
}

.x2mdx-ref-schema-variant-label {
font-size: 0.77rem;
font-weight: 700;
text-transform: uppercase;
color: rgb(107, 114, 128);
}

.x2mdx-ref-schema-variant {
display: grid;
gap: 0.65rem;
min-width: 0;
padding: 0.85rem;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 0.65rem;
background: rgba(248, 250, 252, 0.72);
}

:root.dark .x2mdx-ref-schema-variant,
[data-theme="dark"] .x2mdx-ref-schema-variant {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(17, 24, 39, 0.58);
}

.x2mdx-ref-schema-variant-head {
display: grid;
gap: 0.25rem;
}

.x2mdx-ref-schema-variant-head h4 {
margin: 0;
font-size: 0.94rem;
line-height: 1.3;
overflow-wrap: anywhere;
}

body:has(.x2mdx-ref-hero) #table-of-contents-content a,
body:has(.x2mdx-ref-hero) [aria-label="Table of contents"] a {
overflow-wrap: anywhere;
Expand Down
110 changes: 110 additions & 0 deletions src/x2mdx/asyncapi/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
AsyncApiChannelLifecycle,
AsyncApiMessageDetail,
AsyncApiReport,
AsyncApiSchemaVariantDetail,
AsyncApiSourceSnapshot,
)
from x2mdx.types import JsonObject, JsonValue
Expand Down Expand Up @@ -312,6 +313,113 @@ def schema_sample_value(
return schema_type_token(doc, resolved)


def schema_variant_name(doc: AsyncApiDocument, schema: JsonValue | None, *, index: int) -> str:
ref_name = local_ref_name(schema, prefix="components/schemas")
if ref_name:
return ref_name

resolved = resolve_local_ref(doc, schema)
if not isinstance(resolved, dict):
return f"Variant {index + 1}"

title = resolved.get("title")
if isinstance(title, str) and title.strip():
return title.strip()

properties = resolved.get("properties")
if isinstance(properties, dict) and len(properties) == 1:
return str(next(iter(properties)))

required = resolved.get("required")
if isinstance(required, list) and len(required) == 1:
return str(required[0])

return f"Variant {index + 1}"


def schema_variants(
doc: AsyncApiDocument,
schema: JsonValue | None,
*,
max_depth: int = REQUEST_SAMPLE_MAX_DEPTH,
seen_refs: set[str] | None = None,
) -> list[AsyncApiSchemaVariantDetail]:
if max_depth <= 0:
return []

if seen_refs is None:
seen_refs = set()

if isinstance(schema, dict):
ref = schema.get("$ref")
if isinstance(ref, str):
if ref in seen_refs:
return []
return schema_variants(
doc,
resolve_local_ref(doc, schema),
max_depth=max_depth,
seen_refs=seen_refs | {ref},
)

resolved = resolve_local_ref(doc, schema)
if not isinstance(resolved, dict):
return []

raw_variants = resolved.get("oneOf")
if not isinstance(raw_variants, list):
raw_variants = resolved.get("anyOf")
if not isinstance(raw_variants, list):
property_variants: list[AsyncApiSchemaVariantDetail] = []
properties, required = object_schema_properties_and_required(doc, resolved, max_depth=max_depth)
for property_name, property_schema in properties.items():
nested_variants = schema_variants(
doc,
property_schema,
max_depth=max_depth - 1,
seen_refs=seen_refs,
)
if nested_variants:
property_variants.append(
{
"name": property_name,
"payload_schema": schema_brief(doc, property_schema),
"required_fields": [property_name] if property_name in required else [],
"sample": schema_sample_value(
doc,
property_schema,
max_depth=max_depth,
seen_refs=seen_refs,
),
"variants": nested_variants,
}
)
return property_variants

variants: list[AsyncApiSchemaVariantDetail] = []
for index, variant_schema in enumerate(raw_variants):
variants.append(
{
"name": schema_variant_name(doc, variant_schema, index=index),
"payload_schema": schema_brief(doc, variant_schema),
"required_fields": schema_required_field_names(doc, variant_schema),
"sample": schema_sample_value(
doc,
variant_schema,
max_depth=max_depth,
seen_refs=seen_refs,
),
"variants": schema_variants(
doc,
variant_schema,
max_depth=max_depth - 1,
seen_refs=seen_refs,
),
}
)
return variants


def extract_message_detail(doc: AsyncApiDocument, message_node: JsonValue | None) -> AsyncApiMessageDetail:
resolved_message = resolve_local_ref(doc, message_node)
message_name = local_ref_name(message_node, prefix="components/messages")
Expand All @@ -322,6 +430,7 @@ def extract_message_detail(doc: AsyncApiDocument, message_node: JsonValue | None
"payload_schema": "-",
"required_fields": [],
"sample": None,
"variants": [],
}

payload = resolved_message.get("payload")
Expand All @@ -331,6 +440,7 @@ def extract_message_detail(doc: AsyncApiDocument, message_node: JsonValue | None
"payload_schema": schema_brief(doc, payload) if payload is not None else "-",
"required_fields": schema_required_field_names(doc, payload) if payload is not None else [],
"sample": schema_sample_value(doc, payload) if payload is not None else None,
"variants": schema_variants(doc, payload) if payload is not None else [],
}


Expand Down
9 changes: 9 additions & 0 deletions src/x2mdx/asyncapi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,21 @@ class AsyncApiDocument(TypedDict, total=False):
components: JsonObject


class AsyncApiSchemaVariantDetail(TypedDict):
name: str
payload_schema: str
required_fields: list[str]
sample: JsonValue | None
variants: list["AsyncApiSchemaVariantDetail"]


class AsyncApiMessageDetail(TypedDict):
name: str
content_type: str
payload_schema: str
required_fields: list[str]
sample: JsonValue | None
variants: list[AsyncApiSchemaVariantDetail]


class AsyncApiActionDetail(TypedDict):
Expand Down
22 changes: 21 additions & 1 deletion src/x2mdx/asyncapi/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
ReferenceMetaItem,
ReferenceOperationPage,
ReferencePanel,
ReferenceSchema,
ReferenceSection,
compact_text,
markdown_page_from_template,
Expand Down Expand Up @@ -109,8 +110,17 @@ def action_schema(action: dict[str, Any], *, anchor: str):
message = dict(action.get("message") or {})
sample = message.get("sample")
required_fields = list(message.get("required_fields") or [])
if sample is None and not required_fields:
variant_schemas = [schema_from_variant(variant) for variant in message.get("variants") or []]
if sample is None and not required_fields and not variant_schemas:
return None
if variant_schemas:
return ReferenceSchema(
name=str(message.get("name") or action["action"]),
summary=str(message.get("payload_schema") or "-"),
description=str(action.get("description") or ""),
anchor=anchor,
variants=variant_schemas,
)
return schema_from_sample(
name=str(message.get("name") or action["action"]),
sample=sample,
Expand All @@ -121,6 +131,16 @@ def action_schema(action: dict[str, Any], *, anchor: str):
)


def schema_from_variant(variant: dict[str, Any]) -> ReferenceSchema:
return schema_from_sample(
name=str(variant.get("name") or "Variant"),
sample=variant.get("sample"),
required_fields=list(variant.get("required_fields") or []),
summary=str(variant.get("payload_schema") or "-"),
variants=[schema_from_variant(child) for child in variant.get("variants") or []],
)


def wscat_example(action: dict[str, Any]) -> str:
sample = action["message"].get("sample")
if action["action"] == "publish" and sample is not None:
Expand Down
3 changes: 3 additions & 0 deletions src/x2mdx/reference_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class ReferenceSchema:
description: str = ""
anchor: str | None = None
fields: list[ReferenceField] = field(default_factory=list)
variants: list["ReferenceSchema"] = field(default_factory=list)
enum_values: list[str] = field(default_factory=list)
example: ReferenceExample | None = None

Expand Down Expand Up @@ -233,6 +234,7 @@ def schema_from_sample(
description: str = "",
summary: str = "",
anchor: str | None = None,
variants: list[ReferenceSchema] | None = None,
) -> ReferenceSchema:
required = set(required_fields or [])
fields: list[ReferenceField] = []
Expand Down Expand Up @@ -261,5 +263,6 @@ def schema_from_sample(
description=description,
anchor=anchor,
fields=fields,
variants=variants or [],
example=example,
)
16 changes: 16 additions & 0 deletions src/x2mdx/templates/shared/reference_macros.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,22 @@
<p class="x2mdx-ref-schema-description">{{ escape_mdx_html_text(inline_text(schema.description)) }}</p>
{% endif %}
{{ fields_table(schema.fields) }}
{%- if schema.variants %}
<div class="x2mdx-ref-schema-variants">
<div class="x2mdx-ref-schema-variant-label">Variants</div>
{% for variant in schema.variants %}
<div class="x2mdx-ref-schema-variant">
<div class="x2mdx-ref-schema-variant-head">
<h4>{{ escape_mdx_html_text(inline_text(variant.name)) }}</h4>
{% if variant.summary %}
<p class="x2mdx-ref-schema-summary">{{ escape_mdx_html_text(inline_text(variant.summary)) }}</p>
{% endif %}
</div>
{{ schema_body(variant, render_example=render_example, render_description=render_description) }}
</div>
{% endfor %}
</div>
{%- endif %}
{% if schema.enum_values %}
<ul class="x2mdx-ref-enum-list">
{% for value in schema.enum_values %}
Expand Down
85 changes: 85 additions & 0 deletions tests/test_asyncapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,3 +490,88 @@ def test_action_adapter_builds_operation_page_context(self) -> None:
self.assertEqual(operation.outputs[0].schema.name, "-")
self.assertEqual(operation.examples[0].title, "wscat")
self.assertIn("npx wscat -c <WEBSOCKET_URL>", operation.examples[0].body)

def test_action_adapter_preserves_oneof_response_variants(self) -> None:
channel = build_asyncapi_report_from_sources(
[
self._snapshot(
"1.1.0",
"published/1.1.0/asyncapi.yaml",
"""
asyncapi: 2.6.0
info:
title: Sample WebSocket API
version: 1.1.0
channels:
/updates:
subscribe:
operationId: onUpdates
bindings:
ws:
method: GET
message:
$ref: '#/components/messages/Either_Error_UpdateResponse'
components:
schemas:
Either_Error_UpdateResponse:
title: Either_Error_UpdateResponse
oneOf:
- $ref: '#/components/schemas/Error'
- $ref: '#/components/schemas/UpdateResponse'
Error:
type: object
required: [code]
properties:
code:
type: string
UpdateResponse:
type: object
properties:
update:
$ref: '#/components/schemas/Update'
Update:
title: Update
oneOf:
- type: object
required: [Checkpoint]
properties:
Checkpoint:
type: object
required: [offset]
properties:
offset:
type: string
- type: object
required: [Transaction]
properties:
Transaction:
type: object
required: [updateId]
properties:
updateId:
type: string
messages:
Either_Error_UpdateResponse:
contentType: application/json
payload:
$ref: '#/components/schemas/Either_Error_UpdateResponse'
""",
)
],
source_name="unit test fixtures",
version_filter="unit test versions",
publish_version="1.1.0",
).channels[0]

action = channel.latest["actions"][0]
operation = build_action_operation(channel, action, output_dir=None)
schema = operation.outputs[0].schema

self.assertIsNotNone(schema)
assert schema is not None
self.assertEqual([variant.name for variant in schema.variants], ["Error", "UpdateResponse"])
self.assertEqual(schema.variants[0].fields[0].name, "code")
self.assertEqual(schema.variants[1].fields[0].name, "update")
nested_groups = schema.variants[1].variants
self.assertEqual([variant.name for variant in nested_groups], ["update"])
self.assertEqual([variant.name for variant in nested_groups[0].variants], ["Checkpoint", "Transaction"])