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.
···6677```
88git submodule update --init --recursive
99-pipenv install
1010-```
1111-1212-**Testing:**
1313-1414-```
1515-pipenv run python -m pytest
99+npm install
1610```
17111812## Schema Generation
19132020-The `schema/` folder contains scripts to generate a GraphQL schema from Lexicon. Standard ATProto definitions are initialized in the `deps/atproto` folder.
1414+This folder contains scripts to generate a GraphQL schema from Lexicon. Standard ATProto definitions are initialized in the `deps/atproto` folder.
21152216Example usage:
23172418```
2525-pipenv run python schema/generate_lexicon_schema.py \
1919+npx tsx src/generateLexiconSchema.ts \
2620 app.bsky.actor.getProfile \
2721 com.atproto.server.getSession \
2828- -o schema/schema-generated.graphql
2222+ -o schema-generated.graphql
2923```
30243125This will generate schema definitions for the `getProfile` and `getSession` procedures and recursively import all their referenced types, e.g.
···7367### Building
74687569```bash
7676-cd server
7777-cargo run --release
7070+npm run server
7871```
79728080-The server will start on `http://localhost:8080` by default.
8181-8282-### Environment Configuration
8383-8484-Copy `.env.example` to `.env` and configure as needed for your deployment.
8585-8686-| Variable | Default | Description |
8787-|----------|---------|-------------|
8888-| `HOST` | `0.0.0.0` | Host to bind the server to |
8989-| `PORT` | `8080` | Port to listen on |
7373+The server will start on `http://localhost:8000`.
90749175## License
9276
···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- output: Annotated[
428428- str,
429429- Option(
430430- "--output",
431431- "-o",
432432- help="Output file path (default: stdout)",
433433- ),
434434- ],
435435- append_schema: Annotated[
436436- list[str] | None,
437437- Option(
438438- "--append-schema",
439439- "-a",
440440- help="Additional GraphQL schema files or folders to append. Can be used multiple times.",
441441- ),
442442- ] = None,
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)
-14
server/.env.example
···11-# GraphQL Server Configuration
22-# Copy this file to .env and modify as needed
33-44-# Server host (default: 0.0.0.0 for all interfaces)
55-HOST=0.0.0.0
66-77-# Server port (default: 8080)
88-PORT=8080
99-1010-# Database connection string (if using database)
1111-# DATABASE_URL=postgres://user:password@localhost:5432/mothball
1212-1313-# Logging level (debug, info, warn, error)
1414-# RUST_LOG=info
···11-# Mothball (Backend)
22-33-This repo is for Mothball, an app for selling items locally.
44-55-- The repo is written in Rust.
66-- The primary frameworks are `axum` for powering the web server, `juniper` for powering the GraphQL engine.
···11-[package]
22-name = "mothball-server"
33-version = "0.1.0"
44-edition = "2021"
55-66-[dependencies]
77-axum = "0.8"
88-juniper = "0.17"
99-juniper_axum = "0.3"
1010-serde = { version = "1.0", features = ["derive"] }
1111-serde_json = "1.0"
1212-tokio = { version = "1.0", features = ["full"] }
1313-dotenv = "0.15"
1414-chrono = "0.4"
1515-uuid = { version = "1.0", features = ["v4"] }
-35
server/Dockerfile
···11-# Dockerfile for Mothball GraphQL Server
22-# Multi-stage build to keep the final image small
33-44-# Stage 1: Build the Rust application
55-FROM rust:1.60-slim as builder
66-77-WORKDIR /app
88-99-# Copy the entire project
1010-COPY . .
1111-1212-# Build the application in release mode
1313-RUN cargo build --release
1414-1515-# Stage 2: Create the final lightweight image
1616-FROM debian:bullseye-slim
1717-1818-# Install only the essential dependencies
1919-RUN apt-get update && \
2020- apt-get install -y ca-certificates && \
2121- rm -rf /var/lib/apt/lists/*
2222-2323-WORKDIR /app
2424-2525-# Copy the compiled binary from the builder stage
2626-COPY --from=builder /app/target/release/mothball-server .
2727-2828-# Copy environment variables and static files
2929-COPY .env .
3030-3131-# Expose the port the app runs on
3232-EXPOSE 8080
3333-3434-# Command to run the application
3535-CMD ["./mothball-server"]
-23
server/fly.toml
···11-# fly.toml app configuration
22-# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
33-44-app = "mothball-server"
55-primary_region = "iad"
66-77-[build]
88- dockerfile = "Dockerfile"
99-1010-[env]
1111- PRIMARY_REGION = "iad"
1212-1313-[http_service]
1414- internal_port = 8080
1515- force_https = true
1616- auto_stop_machines = true
1717- auto_start_machines = true
1818- min_machines_running = 1
1919-2020-[[vm]]
2121- cpu_kind = "shared"
2222- cpus = 1
2323- memory_mb = 1024
-58
server/graphql/useInventoryQuery.json
···11-{
22- "data": {
33- "inventory": [
44- {
55- "id": "ikea-desk",
66- "title": "Large IKEA Desk - Spacious Workspace",
77- "cost": "20.00",
88- "images": [
99- "https://www.ikea.com/us/en/images/products/linnmon-adils-desk-black-brown__0974302_pe812345_s5.jpg?f=xl"
1010- ],
1111- "description": "Specifications:\n\n- Dimensions: 79\" long tabletop\n- Color: Black-brown\n- Model: LINNMON with ADILS legs\n- Condition: Used, good overall state\n\nThis versatile desk has been a reliable workspace for multiple years. Ideal for various activities including:\n\n- Computer setup\n- Crafting\n- Project work\n- Organizational tasks\n\nSlight cosmetic wear, minor surface imperfections.\n\nPricing:\n\n- Retail replacement cost: Approximately $100\n- Selling price: $30\n\nFlexible payment options available.",
1212- "location": "Portland, OR",
1313- "condition": "Used - Good",
1414- "category": "Furniture",
1515- "posted_at": "2024-01-15"
1616- },
1717- {
1818- "id": "orbi-router",
1919- "title": "Orbi Router",
2020- "cost": "0",
2121- "images": ["https://www.netgear.com/support/cloudimage/1/11/123573"],
2222- "description": "This is an orbi wireless router. Normally it comes with satellites but\nthis one does not.",
2323- "location": "Portland, OR",
2424- "condition": "Used - Good",
2525- "category": "Electronics",
2626- "posted_at": "2024-01-18"
2727- },
2828- {
2929- "id": "arris-router",
3030- "title": "Arris Router",
3131- "cost": "10.00",
3232- "images": [
3333- "https://m.media-amazon.com/images/I/71gmmM9G8CL._AC_SY879_.jpg"
3434- ],
3535- "description": "This is an Arris SURFBoard router.",
3636- "location": "Portland, OR",
3737- "condition": "Used - Good",
3838- "category": "Electronics",
3939- "posted_at": "2024-01-20"
4040- },
4141- {
4242- "id": "dell-soundbar",
4343- "title": "Dell Stereo SoundBar - AC511M",
4444- "cost": "10.00",
4545- "images": [
4646- "https://m.media-amazon.com/images/I/71MiidZgcfL._AC_SX679_.jpg",
4747- "https://m.media-amazon.com/images/I/71VKkGixWWL._AC_SX679_.jpg"
4848- ],
4949- "description": "- Until you hear it you'll hardly notice it's there Dell Stereo Soundbar can give you the sound you want without big speakers and Long cables.Total usb ports:1\n- USB powered the Dell Stereo Soundbar is simplicity itself Just Plug in to an available USB port on your monitor or laptop No extra software Installation No power cord No Batteries required\n- An included Soundbar mount offers Easy attachment to the monitor allowing you to enjoy clear Stereo sound without losing desk space",
5050- "location": "Portland, OR",
5151- "condition": "Used - Good",
5252- "category": "Electronics",
5353- "posted_at": "2024-01-22"
5454- }
5555- ]
5656- },
5757- "errors": []
5858-}
-167
server/graphql/useMessagesQuery.json
···11-{
22- "data": {
33- "chats": [
44- {
55- "id": "chat:1",
66- "sender": "John Doe",
77- "avatar": "JD",
88- "unread": true,
99- "messages": [
1010- {
1111- "id": "chat:1:1",
1212- "sender": "them",
1313- "text": "Hi, I'm interested in your vintage lamp. Is it still available?",
1414- "time": "2:30 PM"
1515- },
1616- {
1717- "id": "chat:1:2",
1818- "sender": "me",
1919- "text": "Yes, the vintage lamp is still available. Would you like more details about it?",
2020- "time": "2:31 PM"
2121- },
2222- {
2323- "id": "chat:1:3",
2424- "sender": "them",
2525- "text": "Absolutely! How old is it and what's the condition?",
2626- "time": "2:35 PM"
2727- },
2828- {
2929- "id": "chat:1:4",
3030- "sender": "me",
3131- "text": "The lamp is from the 1960s and in excellent condition. The base has a small chip but the shade is perfect.",
3232- "time": "2:38 PM"
3333- }
3434- ]
3535- },
3636- {
3737- "id": "chat:2",
3838- "sender": "Sarah Johnson",
3939- "avatar": "SJ",
4040- "unread": true,
4141- "messages": [
4242- {
4343- "id": "chat:2:1",
4444- "sender": "them",
4545- "text": "Can we negotiate the price on your records collection?",
4646- "time": "1:15 PM"
4747- },
4848- {
4949- "id": "chat:2:2",
5050- "sender": "me",
5151- "text": "Of course! What price were you thinking?",
5252- "time": "1:17 PM"
5353- },
5454- {
5555- "id": "chat:2:3",
5656- "sender": "them",
5757- "text": "I was thinking around $150 for the whole collection. Is that doable?",
5858- "time": "1:20 PM"
5959- },
6060- {
6161- "id": "chat:2:4",
6262- "sender": "me",
6363- "text": "I could do $175, but that's my final offer. The records are in near mint condition.",
6464- "time": "1:22 PM"
6565- }
6666- ]
6767- },
6868- {
6969- "id": "chat:3",
7070- "sender": "Michael Brown",
7171- "avatar": "MB",
7272- "unread": false,
7373- "messages": [
7474- {
7575- "id": "chat:3:1",
7676- "sender": "them",
7777- "text": "Thanks for the vintage camera! Can we arrange pickup tomorrow?",
7878- "time": "Yesterday"
7979- },
8080- {
8181- "id": "chat:3:2",
8282- "sender": "me",
8383- "text": "That sounds great! Would 2 PM work for you?",
8484- "time": "Yesterday"
8585- },
8686- {
8787- "id": "chat:3:3",
8888- "sender": "them",
8989- "text": "Yes, 2 PM is perfect. What's your address?",
9090- "time": "Yesterday"
9191- },
9292- {
9393- "id": "chat:3:4",
9494- "sender": "me",
9595- "text": "I'm at 123 Main Street, Apt 4B. Just ring the buzzer for Brown.",
9696- "time": "Yesterday"
9797- }
9898- ]
9999- },
100100- {
101101- "id": "chat:4",
102102- "sender": "Alex Wilson",
103103- "avatar": "AW",
104104- "unread": false,
105105- "messages": [
106106- {
107107- "id": "chat:4:1",
108108- "sender": "them",
109109- "text": "I'm looking for similar items like your sound system.",
110110- "time": "Monday"
111111- },
112112- {
113113- "id": "chat:4:2",
114114- "sender": "me",
115115- "text": "What specifically are you looking for? I might know where to find similar items.",
116116- "time": "Monday"
117117- },
118118- {
119119- "id": "chat:4:3",
120120- "sender": "them",
121121- "text": "Vinyl record players and mid-century modern speakers.",
122122- "time": "Monday"
123123- },
124124- {
125125- "id": "chat:4:4",
126126- "sender": "me",
127127- "text": "I know a great shop downtown that specializes in exactly that. Want me to text you the address?",
128128- "time": "Monday"
129129- }
130130- ]
131131- },
132132- {
133133- "id": "chat:5",
134134- "sender": "Emily Davis",
135135- "avatar": "ED",
136136- "unread": false,
137137- "messages": [
138138- {
139139- "id": "chat:5:1",
140140- "sender": "them",
141141- "text": "Can you provide more details about your furniture pieces?",
142142- "time": "Last week"
143143- },
144144- {
145145- "id": "chat:5:2",
146146- "sender": "me",
147147- "text": "Sure! The dining table is solid oak with a glass top, and the chairs are original mid-century design.",
148148- "time": "Last week"
149149- },
150150- {
151151- "id": "chat:5:3",
152152- "sender": "them",
153153- "text": "Are the chairs comfortable? I have back problems so seating is important to me.",
154154- "time": "Last week"
155155- },
156156- {
157157- "id": "chat:5:4",
158158- "sender": "me",
159159- "text": "Yes, they're very comfortable! The cushions are original and still in great shape. I can send you photos if you'd like.",
160160- "time": "Last week"
161161- }
162162- ]
163163- }
164164- ]
165165- },
166166- "errors": []
167167-}
-7
server/graphql/useProfileQuery.json
···11-{
22- "data": {
33- },
44- "errors": [
55- {"description":"This query should not make it to the server!"}
66- ]
77-}
···11-"""Test configuration and fixtures for the schema generation tests."""
22-33-import os
44-import sys
55-from pathlib import Path
66-77-# Add the project root to the Python path so we can import modules
88-sys.path.insert(0, str(Path(__file__).parent.parent))
99-1010-1111-def pytest_configure(config):
1212- """Add custom markers if needed."""
1313- pass
-115
tests/test_schema_generation.py
···11-"""Test for schema generation consistency."""
22-33-import os
44-import tempfile
55-from pathlib import Path
66-from typing import List
77-88-from schema.generate_lexicon_schema import generate_definitions, main
99-1010-# Constant for the expected output file
1111-EXPECTED_OUTPUT_FILE = "schema/schema-generated.graphql"
1212-1313-1414-def test_schema_generation_consistency():
1515- """
1616- Test that running the generate_lexicon_schema.py script produces consistent output.
1717- This test ensures that the generated schema doesn't change unexpectedly.
1818- """
1919- # Expected lexicon IDs from the command line
2020- lexicon_ids = ["app.bsky.actor.getProfile", "com.atproto.server.getSession"]
2121-2222- # Generate the expected output using the function directly
2323- generated_content = "\n\n".join(generate_definitions(lexicon_ids))
2424-2525- # Read the expected file if it exists
2626- expected_file_path = Path(EXPECTED_OUTPUT_FILE)
2727- if expected_file_path.exists():
2828- with open(expected_file_path, "r") as f:
2929- expected_content = f.read()
3030-3131- # Compare the generated content with the expected content
3232- assert generated_content == expected_content, (
3333- "Generated schema content does not match the expected file. "
3434- "This could mean the schema has changed or the test needs to be updated."
3535- )
3636-3737- else:
3838- # If the expected file doesn't exist, create it with the current output
3939- with open(expected_file_path, "w") as f:
4040- f.write(generated_content)
4141- print(f"Created new expected output file: {expected_file_path}")
4242-4343-4444-def test_main_function_output():
4545- """
4646- Test the main function by running it with the specified lexicon IDs
4747- and verifying the output is consistent.
4848- """
4949- # Expected lexicon IDs from the command line
5050- lexicon_ids = ["app.bsky.actor.getProfile", "com.atproto.server.getSession"]
5151-5252- # Create a temporary file for testing
5353- with tempfile.NamedTemporaryFile(
5454- mode="w", suffix=".graphql", delete=False
5555- ) as tmp_file:
5656- tmp_path = tmp_file.name
5757-5858- try:
5959- # Run the main function with the lexicon IDs and output to temp file
6060- main(lexicon_ids=lexicon_ids, output=tmp_path, append_schema=None)
6161-6262- # Read the generated content
6363- with open(tmp_path, "r") as f:
6464- generated_content = f.read()
6565-6666- # Read the expected file if it exists
6767- expected_file_path = Path(EXPECTED_OUTPUT_FILE)
6868- if expected_file_path.exists():
6969- with open(expected_file_path, "r") as f:
7070- expected_content = f.read()
7171-7272- # Compare the generated content with the expected content
7373- assert generated_content == expected_content, (
7474- "Generated schema content does not match the expected file. "
7575- "This could mean the schema has changed or the test needs to be updated."
7676- )
7777- else:
7878- # If the expected file doesn't exist, create it with the current output
7979- with open(expected_file_path, "w") as f:
8080- f.write(generated_content)
8181- print(f"Created new expected output file: {expected_file_path}")
8282-8383- finally:
8484- # Clean up the temporary file
8585- if os.path.exists(tmp_path):
8686- os.unlink(tmp_path)
8787-8888-8989-def test_generate_definitions_function():
9090- """
9191- Test the generate_definitions function directly to ensure it produces consistent output.
9292- """
9393- # Expected lexicon IDs from the command line
9494- lexicon_ids = ["app.bsky.actor.getProfile", "com.atproto.server.getSession"]
9595-9696- # Generate definitions using the function
9797- generated_chunks = generate_definitions(lexicon_ids)
9898- generated_content = "\n\n".join(generated_chunks)
9999-100100- # Read the expected file if it exists
101101- expected_file_path = Path(EXPECTED_OUTPUT_FILE)
102102- if expected_file_path.exists():
103103- with open(expected_file_path, "r") as f:
104104- expected_content = f.read()
105105-106106- # Compare the generated content with the expected content
107107- assert generated_content == expected_content, (
108108- "Generated schema content does not match the expected file. "
109109- "This could mean the schema has changed or the test needs to be updated."
110110- )
111111- else:
112112- # If the expected file doesn't exist, create it with the current output
113113- with open(expected_file_path, "w") as f:
114114- f.write(generated_content)
115115- print(f"Created new expected output file: {expected_file_path}")