Select the types of activity you want to include in your feed.
Demonstration bridge between ATproto and GraphQL. Generate schema types and interface with the ATmosphere via GraphQL queries. Includes a TypeScript server with IDE.
···50505151# Other editors
5252. history
5353+5454+# Python
5555+__pycache__
schema/__init__.py
This is a binary file and will not be displayed.
+16
schema/copy_to_json.py
···11+import json
22+import os
33+44+# Get the absolute path of the script's directory
55+script_dir = os.path.dirname(os.path.abspath(__file__))
66+77+# Load the GraphQL schema from file
88+with open(os.path.join(script_dir, "schema-generated.graphql"), "r") as graphql_file:
99+ graphql_schema = graphql_file.read()
1010+1111+# Encode the GraphQL schema as JSON
1212+graphql_json = json.dumps(graphql_schema)
1313+1414+# Save the JSON-encoded schema to a new file
1515+with open(os.path.join(script_dir, "schema-generated.graphql.json"), "w") as json_file:
1616+ _ = json_file.write(graphql_json)
+466
schema/generate_lexicon_schema.py
···11+"""Convert Lexicon definitions to GraphQL"""
22+33+import os
44+import re
55+import sys
66+import traceback
77+from pathlib import Path
88+from typing import Annotated, TypeAlias
99+1010+from atproto_lexicon.models import (
1111+ LexArray,
1212+ LexBlob,
1313+ LexDefinition,
1414+ LexObject,
1515+ LexPrimitive,
1616+ LexRef,
1717+ LexRefUnion,
1818+ LexRefVariant,
1919+ LexString,
2020+ LexXrpcQuery,
2121+)
2222+from atproto_lexicon.parser import lexicon_parse_file
2323+from typer import Argument, Option, run
2424+2525+2626+def normalize_type_name(s: str) -> str:
2727+ """
2828+ Normalize type name to PascalCase
2929+ """
3030+ # Convert to PascalCase
3131+ return s[0].upper() + s[1:] if s else s
3232+3333+3434+class LexiconPath:
3535+ _segments: list[str]
3636+3737+ def __init__(self, path: str | None = None) -> None:
3838+ if path is None:
3939+ self._segments = []
4040+ else:
4141+ self._segments = path.split(".")
4242+4343+ @property
4444+ def segments(self):
4545+ return self._segments[:]
4646+4747+ def dot_path(self):
4848+ return ".".join(self._segments)
4949+5050+ def file_path(self):
5151+ return "/".join(self._segments)
5252+5353+5454+# Constants
5555+BASE_DIR = os.path.dirname(os.path.abspath(__file__))
5656+LEXICON_DIR = os.path.join(BASE_DIR, "../deps/atproto/lexicons")
5757+5858+TYPE_MAPPING = {
5959+ "string": "String",
6060+ "integer": "Int",
6161+ "number": "Float",
6262+ "boolean": "Boolean",
6363+ "bytes": "String",
6464+}
6565+6666+FORMAT_MAPPING = {
6767+ "did": "ID",
6868+ "handle": "ID",
6969+ "uri": "String",
7070+ "cid": "String",
7171+ "datetime": "String",
7272+ "date": "String",
7373+ "time": "String",
7474+ "at-identifier": "ID",
7575+}
7676+7777+SKIP_FIELDS = {"debug"} # Fields to skip in GraphQL output
7878+7979+8080+def resolve_lexicon_path(lexicon_path: LexiconPath) -> str:
8181+ """
8282+ Resolve a lexicon ID to its file path
8383+ """
8484+ dir_parts = lexicon_path.segments
8585+8686+ # Construct path: app.bsky.actor.getProfile -> app/bsky/actor/getProfile.json
8787+ file_path = os.path.join(LEXICON_DIR, *dir_parts[:-1], f"{dir_parts[-1]}.json")
8888+8989+ if not os.path.exists(file_path):
9090+ raise FileNotFoundError(f"Lexicon file not found: {file_path}")
9191+9292+ return file_path
9393+9494+9595+def normalize_reference(base: str, ref: str) -> str:
9696+ """
9797+ Normalize a reference like '#profileViewDetailed'
9898+ """
9999+ if "#" in base and ref[0] == "#":
100100+ return base.split("#")[0] + ref
101101+ elif "#" not in base and ref[0] == "#":
102102+ return base + ref
103103+ else:
104104+ return ref
105105+106106+107107+def resolve_reference(ref: str) -> LexDefinition | None:
108108+ if "#" in ref:
109109+ lexicon_id, type_name = ref.split("#", 1)
110110+ else:
111111+ lexicon_id = ref
112112+ type_name = "main"
113113+ try:
114114+ lexicon_path = resolve_lexicon_path(LexiconPath(lexicon_id))
115115+116116+ # Use structured parsing instead of JSON
117117+ lexicon_structured_data = lexicon_parse_file(lexicon_path)
118118+119119+ # Find the type definition in the structured data
120120+ if not lexicon_structured_data:
121121+ return None
122122+123123+ defs = lexicon_structured_data.defs
124124+125125+ # Check if the type_name is in defs directly (as dict key)
126126+ if type_name in defs:
127127+ return defs[type_name]
128128+129129+ return None
130130+ except Exception as e:
131131+ print(f"Warning: Could not resolve reference {ref}: {e}", file=sys.stderr)
132132+ return None
133133+134134+135135+def lex_type_to_graphql_type(
136136+ base: str,
137137+ lex_type: LexRefVariant | LexPrimitive | LexBlob | LexObject | LexArray,
138138+ is_required: bool = False,
139139+) -> str:
140140+ """
141141+ Convert Lexicon type to GraphQL type
142142+ """
143143+ if isinstance(lex_type, LexArray):
144144+ items_type = lex_type.items
145145+ item_type = lex_type_to_graphql_type(base, items_type, True)
146146+ output = f"[{item_type}]"
147147+ elif isinstance(lex_type, LexRef):
148148+ # For reference types, we use the type name directly
149149+ ref = normalize_reference(base, lex_type.ref) or ""
150150+ # Convert to GraphQL naming convention - camelCase to PascalCase
151151+ type_name = f"Lexicon_{ref.replace('#', '.').replace('.', '_')}"
152152+ output = type_name
153153+ elif isinstance(lex_type, LexString):
154154+ if lex_type.format in FORMAT_MAPPING:
155155+ output = FORMAT_MAPPING[lex_type.format]
156156+ else:
157157+ output = "String"
158158+ elif lex_type.type in TYPE_MAPPING:
159159+ output = TYPE_MAPPING[lex_type.type]
160160+ else:
161161+ output = "Unknown"
162162+163163+ nullable_indicator = "!" if is_required else ""
164164+ return f"{output}{nullable_indicator}"
165165+166166+167167+TypeDef: TypeAlias = LexObject | LexString
168168+TypeDefs: TypeAlias = dict[str, TypeDef]
169169+170170+171171+def to_upper_snake_case(s: str) -> str:
172172+ return re.sub(r"[^a-zA-Z0-9]+", "_", s).upper()
173173+174174+175175+def lex_object_definition_to_graphql(type_name: str, type_def: TypeDef) -> str:
176176+ """
177177+ Convert a LexObject definition from lexicon to GraphQL
178178+ """
179179+180180+ if isinstance(type_def, LexString):
181181+ # Enums
182182+ graphql_lines = [
183183+ f"# {type_name}\nenum Lexicon_{type_name.replace('#', '_').replace('.', '_')} {{"
184184+ ]
185185+186186+ for field_name in type_def.known_values or []:
187187+ graphql_lines.append(f" {to_upper_snake_case(field_name)}")
188188+ else:
189189+ # Struct/object definitions
190190+ graphql_lines = [
191191+ f"# {type_name}\ntype Lexicon_{type_name.replace('#', '_').replace('.', '_')} {{"
192192+ ]
193193+194194+ properties = type_def.properties
195195+ required_fields = set(type_def.required or [])
196196+197197+ for field_name, field_def in properties.items():
198198+ # Skip debug field as per requirements
199199+ if field_name in SKIP_FIELDS:
200200+ continue
201201+202202+ # Skip ref unions(?)
203203+ if isinstance(field_def, LexRefUnion):
204204+ continue
205205+206206+ # Skip "unknown" fields
207207+ if field_def.type == "unknown":
208208+ continue
209209+210210+ is_required = field_name in required_fields
211211+ graphql_type = lex_type_to_graphql_type(type_name, field_def, is_required)
212212+ graphql_lines.append(f" {field_name}: {graphql_type}")
213213+214214+ graphql_lines.append("}")
215215+ return "\n".join(graphql_lines)
216216+217217+218218+# Build the nested types from the outside in
219219+# For app.bsky.actor.getProfile, we need:
220220+# 1. type LexiconApp { bsky: LexiconAppBsky! }
221221+# 2. type LexiconAppBsky { actor: LexiconAppBskyActor! }
222222+# 3. type LexiconAppBskyActor { getProfile(...): ... }
223223+def output_lexicon_namespaces(
224224+ namespaces: dict[str, str], root: LexiconPath
225225+) -> list[str]:
226226+ lines: list[str] = []
227227+ chunks: list[str] = []
228228+ root_len = len(root.segments)
229229+ while len(namespaces):
230230+ lexicon_path = LexiconPath(next(iter(namespaces)))
231231+ if root_len == len(lexicon_path.segments) - 1:
232232+ # Done, output the type
233233+ lines.append(namespaces.pop(lexicon_path.dot_path()))
234234+ else:
235235+ next_segment = lexicon_path.segments[root_len]
236236+ next_path = (
237237+ f"{root.dot_path()}.{next_segment}" if root_len > 0 else next_segment
238238+ )
239239+240240+ group: dict[str, str] = dict()
241241+ for path in list(namespaces.keys()):
242242+ if path.startswith(f"{next_path}."):
243243+ group[path] = namespaces.pop(path)
244244+ chunks += output_lexicon_namespaces(group, LexiconPath(next_path))
245245+246246+ lines.append(
247247+ f"{next_segment}: {'_'.join(['Lexicon'] + LexiconPath(next_path).segments)}!"
248248+ )
249249+250250+ content = " " + "\n ".join(lines)
251251+ chunks.append(f"type {'_'.join(['Lexicon'] + root.segments)} {{\n{content}\n}}\n")
252252+ return chunks
253253+254254+255255+def generate_lexicon_structure(
256256+ lexicon_path: LexiconPath,
257257+) -> tuple[str, TypeDefs]:
258258+ """
259259+ Generate the nested Lexicon structure for a lexicon endpoint
260260+ Returns (graphql_type, set(refs))
261261+ """
262262+263263+ method_name = lexicon_path.segments[-1]
264264+265265+ try:
266266+ # Structured parsing of the Lexicon .json file
267267+ lexicon_structured_data = lexicon_parse_file(resolve_lexicon_path(lexicon_path))
268268+269269+ if not lexicon_structured_data:
270270+ return ("", dict())
271271+272272+ defs = lexicon_structured_data.defs
273273+ main_def = defs["main"]
274274+ assert isinstance(main_def, LexXrpcQuery)
275275+276276+ # Build the method definition
277277+ referenced_types: set[str] = set()
278278+ object_definitions: TypeDefs = dict()
279279+280280+ # Add parameters as fields
281281+ method_fields: list[str] = []
282282+ if main_def.parameters:
283283+ params = main_def.parameters
284284+ required_fields = params.required or []
285285+ for field_name, field_def in params.properties.items():
286286+ # Convert the structured field definition to dict for get_graphql_type
287287+ is_required = field_name in required_fields
288288+ graphql_type = lex_type_to_graphql_type(
289289+ lexicon_path.dot_path(), field_def, is_required
290290+ )
291291+ method_fields.append(f"{field_name}: {graphql_type}")
292292+293293+ # Sweep up refs
294294+ if isinstance(field_def, LexRef):
295295+ referenced_types.add(field_def.ref)
296296+297297+ method_params = f"({', '.join(method_fields)})" if len(method_fields) else ""
298298+299299+ return_type = None
300300+301301+ if main_def.output:
302302+ output_schema = main_def.output.schema_
303303+ if isinstance(output_schema, LexRef):
304304+ # Use the output type as return type
305305+ return_type = lex_type_to_graphql_type(
306306+ lexicon_path.dot_path(), output_schema
307307+ )
308308+309309+ # Sweep up refs
310310+ referenced_types.add(output_schema.ref)
311311+ elif isinstance(output_schema, LexObject):
312312+ # Handle inline object definitions for return type
313313+ output_type = f"Lexicon_{'_'.join(lexicon_path.segments)}Output"
314314+ return_type = output_type
315315+ object_definitions[lexicon_path.dot_path() + "Output"] = output_schema
316316+317317+ # Resolve references
318318+ for ref in referenced_types:
319319+ ref = normalize_reference(lexicon_path.dot_path(), ref)
320320+ ref_def = resolve_reference(ref)
321321+ if isinstance(ref_def, TypeDef):
322322+ object_definitions[ref] = ref_def
323323+324324+ if return_type:
325325+ method_def = f"{method_name}{method_params}: {return_type}"
326326+ else:
327327+ method_def = f"{method_name}{method_params}"
328328+329329+ return (method_def, object_definitions)
330330+ except Exception as e:
331331+ print(
332332+ f"Error processing lexicon {'.'.join(lexicon_path.segments)}: {e}",
333333+ file=sys.stderr,
334334+ )
335335+ traceback.print_exc()
336336+ return ("", dict())
337337+338338+339339+def generate_definitions(lexicon_ids: list[str]) -> list[str]:
340340+ chunks: list[str] = []
341341+ lexicon_fields: dict[str, str] = dict()
342342+ object_definitions: TypeDefs = dict()
343343+ for lexicon_id in lexicon_ids:
344344+ lexicon_path = LexiconPath(lexicon_id)
345345+346346+ # Generate main lexicon structure
347347+ method_type, collected_object_definitions = generate_lexicon_structure(
348348+ lexicon_path
349349+ )
350350+ lexicon_fields[lexicon_path.dot_path()] = method_type
351351+ object_definitions.update(collected_object_definitions)
352352+353353+ # Walk output types for more refs
354354+ final_definitions: TypeDefs = dict()
355355+ new_definitions: TypeDefs = dict()
356356+ while len(object_definitions):
357357+ for obj_name, obj_def in object_definitions.items():
358358+ if isinstance(obj_def, LexObject):
359359+ for field_def in obj_def.properties.values():
360360+ # Export refs
361361+ if isinstance(field_def, LexRef):
362362+ ref = normalize_reference(obj_name, field_def.ref)
363363+ ref_def = resolve_reference(ref)
364364+ if isinstance(ref_def, TypeDef):
365365+ new_definitions[ref] = ref_def
366366+ continue
367367+ elif isinstance(field_def, LexArray) and isinstance(
368368+ field_def.items, LexRef
369369+ ):
370370+ ref = normalize_reference(obj_name, field_def.items.ref)
371371+ ref_def = resolve_reference(ref)
372372+ if isinstance(ref_def, LexObject) or isinstance(
373373+ ref_def, LexString
374374+ ):
375375+ new_definitions[ref] = ref_def
376376+ continue
377377+378378+ final_definitions[obj_name] = obj_def
379379+380380+ # Reset object_definitions to only be ones we haven't seen yet.
381381+ object_definitions = dict()
382382+ for obj_key, obj_def in new_definitions.items():
383383+ if obj_key not in final_definitions:
384384+ object_definitions[obj_key] = obj_def
385385+ object_definitions = final_definitions
386386+387387+ # Generate types for all output types
388388+ for name, type in object_definitions.items():
389389+ chunks.append(lex_object_definition_to_graphql(name, type) + "\n\n")
390390+391391+ # Generate Lexicon structs iteratively.
392392+ chunks += output_lexicon_namespaces(lexicon_fields, LexiconPath())
393393+394394+ return chunks
395395+396396+397397+def read_schema_files(schema_files: list[str] | None) -> str:
398398+ """Read content from schema files or folders recursively."""
399399+ if not schema_files:
400400+ return ""
401401+402402+ output = ""
403403+ for schema_file in schema_files:
404404+ path = Path(schema_file)
405405+ if path.is_file():
406406+ # If it's a single file, read it
407407+ with open(str(path), "r") as f:
408408+ content = f.read()
409409+ output += "\n" + content + "\n"
410410+ elif path.is_dir():
411411+ # If it's a directory, recursively read all .graphql files
412412+ for graphql_file in path.rglob("*.graphql"):
413413+ with open(str(graphql_file), "r") as f:
414414+ content = f.read()
415415+ output += "\n" + content + "\n"
416416+ return output
417417+418418+419419+def main(
420420+ lexicon_ids: Annotated[
421421+ list[str],
422422+ Argument(
423423+ ...,
424424+ help="List of lexicon identifiers to generate definitions for",
425425+ ),
426426+ ],
427427+ append_schema: Annotated[
428428+ list[str],
429429+ Option(
430430+ "--append-schema",
431431+ "-a",
432432+ help="Additional GraphQL schema files or folders to append. Can be used multiple times.",
433433+ ),
434434+ ],
435435+ output: Annotated[
436436+ str,
437437+ Option(
438438+ "--output",
439439+ "-o",
440440+ help="Output file path (default: stdout)",
441441+ ),
442442+ ],
443443+) -> None:
444444+ """Generate GraphQL schema from lexicon_ids and source files."""
445445+446446+ output_content: str = ""
447447+448448+ # Append additional schemas if provided via --append-schema
449449+ if append_schema:
450450+ additional_schemas = read_schema_files(append_schema)
451451+ output_content += additional_schemas
452452+453453+ # Append the new schema.
454454+ output_content += "\n\n".join(generate_definitions(lexicon_ids))
455455+456456+ # Write to output file
457457+ if output:
458458+ output_path: str = output
459459+ with open(output_path, "w") as f:
460460+ _ = f.write(output_content)
461461+ else:
462462+ print(output)
463463+464464+465465+if __name__ == "__main__":
466466+ run(main)