Move peo package to a different spot to prepare it for PyPI

This commit is contained in:
Bart Broere committed 2026-10-21 10:59:30 +02:00
1 parent 9a25aa11c9
commit 3e8e498b56
16 files changed
+408 -59

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""peo: serves neo4j-browser and translates Bolt/Cypher to PostgreSQL + AGE."""
__version__ = "0.1.0"
+57
View File
@@ -0,0 +1,57 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""peo: serves neo4j-browser and translates Bolt/Cypher to PostgreSQL + AGE."""
import asyncio
import functools
import logging
import os
from .backend import Config
from .bolt import handle_client
from .web import HttpServer
def env(name, default):
return os.environ.get(name) or default
async def main():
logging.basicConfig(level=env("PEO_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
log = logging.getLogger("peo")
bolt_port = int(env("PEO_BOLT_PORT", "7687"))
http_port = int(env("PEO_HTTP_PORT", "7474"))
advertised_port = env("PEO_ADVERTISED_BOLT_PORT", str(bolt_port))
config = Config(
pg_host=env("PEO_PG_HOST", "127.0.0.1"),
pg_port=int(env("PEO_PG_PORT", "5432")),
pg_database=env("PEO_PG_DATABASE", env("POSTGRES_DB", env("POSTGRES_USER", "postgres"))),
default_graph=env("PEO_DEFAULT_GRAPH", "neo4j"),
advertised_bolt=f"{env('PEO_ADVERTISED_HOST', 'localhost')}:{advertised_port}",
)
bolt = await asyncio.start_server(functools.partial(handle_client, config=config), "0.0.0.0", bolt_port)
servers = [bolt]
log.info("Bolt listening on :%d (PostgreSQL %s:%d/%s, default graph %r)", bolt_port,
config.pg_host, config.pg_port, config.pg_database, config.default_graph)
browser_dir = env("PEO_BROWSER_DIR", "")
if browser_dir:
http = HttpServer(browser_dir, bolt_port)
servers.append(await asyncio.start_server(http.handle, "0.0.0.0", http_port))
log.info("neo4j-browser on http://localhost:%d/browser/", http_port)
await asyncio.gather(*(s.serve_forever() for s in servers))
def run():
try:
asyncio.run(main())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
run()
+151
View File
@@ -0,0 +1,151 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""Parser for the text representation of AGE's ``agtype``.
agtype text is JSON extended with ``NaN``/``Infinity`` and type annotations
such as ``{...}::vertex``, ``{...}::edge``, ``[...]::path`` and ``1.5::numeric``.
"""
import json
from .packstream import Node, Path, Relationship
# Labels AGE uses for vertices/edges created without an explicit label.
DEFAULT_LABELS = ("", "_ag_label_vertex", "_ag_label_edge")
_SPECIAL = {"true": True, "false": False, "null": None,
"NaN": float("nan"), "Infinity": float("inf"), "-Infinity": float("-inf")}
class AgtypeError(ValueError):
pass
def parse(text):
if text is None:
return None
parser = _Parser(text)
value = parser.value()
parser.ws()
if parser.pos != len(text):
raise AgtypeError(f"trailing data in agtype value: {text!r}")
return value
class _Parser:
def __init__(self, text):
self.s = text
self.pos = 0
def ws(self):
s, n = self.s, len(self.s)
while self.pos < n and s[self.pos] in " \t\r\n":
self.pos += 1
def value(self):
self.ws()
if self.pos >= len(self.s):
raise AgtypeError("unexpected end of agtype value")
c = self.s[self.pos]
if c == "{":
v = self.obj()
elif c == "[":
v = self.arr()
elif c == '"':
v = self.string()
else:
v = self.scalar()
return self.annotate(v)
def annotate(self, v):
if not self.s.startswith("::", self.pos):
return v
self.pos += 2
start = self.pos
while self.pos < len(self.s) and (self.s[self.pos].isalnum() or self.s[self.pos] == "_"):
self.pos += 1
kind = self.s[start:self.pos]
if kind == "vertex":
label = v.get("label", "")
return Node(v["id"], [] if label in DEFAULT_LABELS else [label], v.get("properties") or {})
if kind == "edge":
return Relationship(v["id"], v["start_id"], v["end_id"], v.get("label", ""),
v.get("properties") or {})
if kind == "path":
return Path(v[0::2], v[1::2])
if kind == "numeric":
return float(v)
return v
def obj(self):
self.pos += 1
result = {}
self.ws()
if self.s[self.pos] == "}":
self.pos += 1
return result
while True:
self.ws()
key = self.string()
self.ws()
self.expect(":")
result[key] = self.value()
self.ws()
if self.s[self.pos] == ",":
self.pos += 1
continue
self.expect("}")
return result
def arr(self):
self.pos += 1
result = []
self.ws()
if self.s[self.pos] == "]":
self.pos += 1
return result
while True:
result.append(self.value())
self.ws()
if self.s[self.pos] == ",":
self.pos += 1
continue
self.expect("]")
return result
def string(self):
if self.s[self.pos] != '"':
raise AgtypeError(f"expected string at {self.pos} in {self.s!r}")
i = self.pos + 1
n = len(self.s)
while i < n:
c = self.s[i]
if c == "\\":
i += 2
continue
if c == '"':
break
i += 1
raw = self.s[self.pos:i + 1]
self.pos = i + 1
return json.loads(raw)
def scalar(self):
start = self.pos
n = len(self.s)
while self.pos < n and self.s[self.pos] not in ",]}: \t\r\n":
self.pos += 1
token = self.s[start:self.pos]
if token in _SPECIAL:
return _SPECIAL[token]
try:
if any(ch in token for ch in ".eE") and not token.lstrip("-").isdigit():
return float(token)
return int(token)
except ValueError:
raise AgtypeError(f"invalid agtype token {token!r}") from None
def expect(self, ch):
if self.pos >= len(self.s) or self.s[self.pos] != ch:
raise AgtypeError(f"expected {ch!r} at {self.pos} in {self.s!r}")
self.pos += 1
+398
View File
@@ -0,0 +1,398 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""Executes Bolt queries against PostgreSQL + Apache AGE.
Every Neo4j "database" maps to an AGE graph. The special ``system`` database
only answers the administrative queries that neo4j-browser issues.
"""
import logging
import re
import secrets
import time
from dataclasses import dataclass, field
import psycopg
from psycopg import errors as pg_errors
from psycopg import sql
from . import agtype
from .cypher import (CypherError, expand_id_in_lists, first_keyword, inline_parameters,
return_columns, rewrite_for_age, strip_statement)
from .packstream import Node, Relationship
log = logging.getLogger("peo.backend")
NEO4J_VERSION = "5.26.0"
SYSTEM_DB = "system"
SYSTEM_LABELS = ("_ag_label_vertex", "_ag_label_edge")
GRAPH_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.\-]{2,62}$")
# Procedures emulated by peo, reported through SHOW PROCEDURES.
PROCEDURES = [
("db.labels", "List all node labels in the graph.", "db.labels() :: (label :: STRING)"),
("db.relationshipTypes", "List all relationship types in the graph.",
"db.relationshipTypes() :: (relationshipType :: STRING)"),
("db.propertyKeys", "List property keys in the graph.", "db.propertyKeys() :: (propertyKey :: STRING)"),
("db.schema.visualization", "Visualize the schema of the graph.",
"db.schema.visualization() :: (nodes :: LIST<NODE>, relationships :: LIST<RELATIONSHIP>)"),
("dbms.components", "List DBMS components and their versions.",
"dbms.components() :: (name :: STRING, versions :: LIST<STRING>, edition :: STRING)"),
("dbms.clientConfig", "Return config settings interesting to clients.",
"dbms.clientConfig() :: (name :: STRING, value :: STRING)"),
("dbms.showCurrentUser", "Show the current user.",
"dbms.showCurrentUser() :: (username :: STRING, roles :: LIST<STRING>, flags :: LIST<STRING>)"),
]
CLIENT_CONFIG = {
"browser.allow_outgoing_connections": "true",
"browser.credential_timeout": "0s",
"browser.retain_connection_credentials": "true",
"browser.retain_editor_history": "true",
"client.allow_telemetry": "false",
"dbms.security.auth_enabled": "true",
}
@dataclass
class Config:
pg_host: str
pg_port: int
pg_database: str
default_graph: str
advertised_bolt: str
@dataclass
class Result:
fields: list
rows: list = field(default_factory=list)
db: str = ""
query_type: str = "r"
t_first: int = 0
class BoltFailure(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
def failure_from_pg(e: Exception) -> BoltFailure:
"""Map a PostgreSQL/AGE error onto a Neo4j status code."""
sqlstate = getattr(e, "sqlstate", None) or ""
diag = getattr(e, "diag", None)
message = ((diag.message_primary if diag else None) or str(e)).strip()
if sqlstate == "42601":
code = "Neo.ClientError.Statement.SyntaxError"
elif sqlstate.startswith("42"):
code = "Neo.ClientError.Statement.SemanticError"
elif sqlstate.startswith("22"):
code = "Neo.ClientError.Statement.ArgumentError"
elif sqlstate.startswith("23"):
code = "Neo.ClientError.Schema.ConstraintValidationFailed"
elif sqlstate.startswith("28"):
code = "Neo.ClientError.Security.Unauthorized"
elif sqlstate.startswith("40"):
code = "Neo.TransientError.Transaction.DeadlockDetected"
elif sqlstate == "57014":
code = "Neo.ClientError.Transaction.TransactionTimedOut"
elif sqlstate.startswith("08") or isinstance(e, psycopg.OperationalError):
code = "Neo.TransientError.General.DatabaseUnavailable"
else:
code = "Neo.DatabaseError.General.UnknownError"
return BoltFailure(code, message)
def _norm(query: str) -> str:
return re.sub(r"\s+", " ", strip_statement(query)).strip()
class Session:
"""One PostgreSQL connection, owned by one Bolt connection."""
def __init__(self, config: Config, user: str):
self.config = config
self.user = user
self.conn: psycopg.AsyncConnection | None = None
self.tx_db: str | None = None
async def connect(self, password: str):
try:
self.conn = await psycopg.AsyncConnection.connect(
host=self.config.pg_host, port=self.config.pg_port, dbname=self.config.pg_database,
user=self.user, password=password, autocommit=True,
application_name="peo")
except psycopg.OperationalError as e:
msg = str(e)
if "password authentication failed" in msg or "role" in msg and "does not exist" in msg:
raise BoltFailure("Neo.ClientError.Security.Unauthorized",
"The client is unauthorized due to authentication failure.") from e
raise BoltFailure("Neo.TransientError.General.DatabaseUnavailable",
f"Could not connect to PostgreSQL: {msg.strip()}") from e
try:
await self.conn.execute("LOAD 'age'")
except psycopg.Error:
pass # Already preloaded, or not permitted for this role.
await self.conn.execute('SET search_path = ag_catalog, "$user", public')
async def close(self):
if self.conn is not None:
await self.conn.close()
self.conn = None
# -- transactions ---------------------------------------------------------
@property
def in_transaction(self):
return self.tx_db is not None
async def begin(self, db):
db = await self.resolve_db(db)
await self._sql("BEGIN")
self.tx_db = db
async def commit(self):
self.tx_db = None
await self._sql("COMMIT")
async def rollback(self):
self.tx_db = None
if self.conn is not None and not self.conn.closed:
try:
await self._sql("ROLLBACK")
except BoltFailure:
pass
async def reset(self):
await self.rollback()
# -- query execution ------------------------------------------------------
async def _sql(self, query, params=None):
try:
cur = await self.conn.execute(query, params)
except psycopg.Error as e:
raise failure_from_pg(e) from e
return await cur.fetchall() if cur.description else []
async def graphs(self):
rows = await self._sql("SELECT name FROM ag_catalog.ag_graph ORDER BY name")
return [r[0] for r in rows]
async def resolve_db(self, db):
if not db:
db = self.config.default_graph
if db.lower() == SYSTEM_DB:
return SYSTEM_DB
for name in await self.graphs():
if name == db or name.lower() == db.lower():
return name
raise BoltFailure("Neo.ClientError.Database.DatabaseNotFound",
f"Database does not exist. Database name: '{db}'.")
async def run(self, query: str, params: dict, db: str | None) -> Result:
started = time.monotonic()
db = self.tx_db if self.in_transaction else await self.resolve_db(db)
try:
result = await self._run_system(query, db)
if result is None:
if db == SYSTEM_DB:
raise BoltFailure("Neo.ClientError.Statement.NotSystemDatabaseCommand",
"This is an administration command and it should be executed "
"against the system database: peo only supports a few of them.")
result = await self._run_cypher(query, params or {}, db)
except CypherError as e:
raise BoltFailure(e.code, e.message) from e
result.db = db
result.t_first = int((time.monotonic() - started) * 1000)
return result
async def _run_cypher(self, query, params, graph) -> Result:
text = strip_statement(query)
keyword = first_keyword(text)
if keyword in ("EXPLAIN", "PROFILE"):
raise CypherError("Neo.ClientError.Statement.FeatureNotSupported",
f"{keyword} is not supported by Apache AGE.")
text = expand_id_in_lists(inline_parameters(rewrite_for_age(text), params))
columns = return_columns(text)
tag = "peo"
while f"${tag}$" in text:
tag = "peo_" + secrets.token_hex(4)
col_defs = ", ".join(f"c{i} ag_catalog.agtype" for i in range(max(len(columns), 1)))
stmt = (f"SELECT * FROM ag_catalog.cypher({sql.Literal(graph).as_string(self.conn)}, "
f"${tag}$ {text} ${tag}$) AS ({col_defs})")
log.debug("SQL: %s", stmt)
rows = await self._sql(stmt)
if columns:
rows = [[agtype.parse(v) for v in row] for row in rows]
else:
rows = []
writes = re.search(r"(?<![\w.])(CREATE|MERGE|SET|DELETE|REMOVE)(?!\w)", text, re.IGNORECASE)
return Result(columns, rows, query_type="rw" if writes else "r")
# -- emulated Neo4j procedures ---------------------------------------------
async def _run_system(self, query, db) -> Result | None:
q = _norm(query)
ql = q.lower()
if ql.startswith("call dbms.components()"):
return Result(["name", "versions", "edition"], [["Neo4j Kernel", [NEO4J_VERSION], "community"]])
if ql.startswith("call dbms.clientconfig()") or ql.startswith("call dbms.listconfig()"):
return Result(["name", "value"], [[k, v] for k, v in CLIENT_CONFIG.items()])
if ql.startswith("call dbms.showcurrentuser()") or ql == "show current user":
return Result(["username", "roles", "flags"], [[self.user, ["admin"], []]])
if ql.startswith("show procedures"):
return Result(["name", "description", "signature"], [list(p) for p in PROCEDURES])
if ql.startswith("show functions"):
return Result(["name", "description", "signature"], [])
if re.match(r"^show (range |lookup |fulltext |text |point |vector |all )?indexes", ql) \
or ql.startswith("call db.indexes()"):
return Result(["id", "name", "state", "populationPercent", "type", "entityType",
"labelsOrTypes", "properties", "indexProvider", "owningConstraint"], [])
if re.match(r"^show (all |unique |exist(ence)? |node key )?constraints", ql) \
or ql.startswith("call db.constraints()"):
return Result(["id", "name", "type", "entityType", "labelsOrTypes", "properties",
"ownedIndex", "propertyType"], [])
m = re.match(r"^show (?:(home|default) database|databases|database (`[^`]+`|[\w.\-]+))(?!\w)",
q, re.IGNORECASE)
if m:
return await self._show_databases(m.group(1), m.group(2))
m = re.match(r"^create (?:database|graph) (`[^`]+`|[\w.\-]+)( if not exists)?$", q, re.IGNORECASE)
if m:
return await self._create_graph(m.group(1).strip("`"), bool(m.group(2)))
m = re.match(r"^drop (?:database|graph) (`[^`]+`|[\w.\-]+)( if exists)?", q, re.IGNORECASE)
if m:
return await self._drop_graph(m.group(1).strip("`"), bool(m.group(2)))
if db == SYSTEM_DB:
return None
# neo4j-browser's combined metadata queries (see dbMetaDuck.ts upstream).
if "call db.labels() yield label" in ql and "db.relationshiptypes()" in ql and "union all" in ql:
labels, types = await self._labels(db)
return Result(["result"], [[{"name": "labels", "data": labels}],
[{"name": "relationshipTypes", "data": types}],
[{"name": "propertyKeys", "data": await self._property_keys(db)}]])
if "return { name:'nodes', data:count(*) } as result" in ql:
nodes, rels = await self._counts(db)
return Result(["result"], [[{"name": "nodes", "data": nodes}],
[{"name": "relationships", "data": rels}]])
if re.match(r"^call db\.labels\(\)", ql):
labels, _ = await self._labels(db)
return Result(["label"], [[x] for x in labels])
if re.match(r"^call db\.relationshiptypes\(\)", ql):
_, types = await self._labels(db)
return Result(["relationshipType"], [[x] for x in types])
if re.match(r"^call db\.propertykeys\(\)", ql):
return Result(["propertyKey"], [[x] for x in await self._property_keys(db)])
if re.match(r"^call db\.schema\.visualization\(\)", ql):
return await self._schema_visualization(db)
m = re.match(r"^call (db|dbms|apoc|gds)\.[\w.]+", ql)
if m:
raise BoltFailure("Neo.ClientError.Procedure.ProcedureNotFound",
f"There is no procedure with the name `{m.group(0)[5:]}` registered "
"for this database instance (peo).")
return None
async def _show_databases(self, special, name):
graphs = await self.graphs()
default = self.config.default_graph
if default not in graphs and graphs:
default = graphs[0]
rows = []
for g in [SYSTEM_DB] + graphs:
is_default = g == default
rows.append([g, "system" if g == SYSTEM_DB else "standard", [], "read-write",
self.config.advertised_bolt, "primary", True, "online", "online", "",
is_default, is_default, []])
if special:
rows = [r for r in rows if r[10]]
elif name:
name = name.strip("`").lower()
rows = [r for r in rows if r[0].lower() == name]
return Result(["name", "type", "aliases", "access", "address", "role", "writer",
"requestedStatus", "currentStatus", "statusMessage", "default", "home",
"constituents"], rows)
async def _create_graph(self, name, if_not_exists):
if not GRAPH_NAME.match(name):
raise BoltFailure("Neo.ClientError.Statement.ArgumentError",
f"Invalid graph name '{name}': AGE graph names must be 3-63 characters "
"of letters, digits, '_', '.' or '-', starting with a letter or '_'.")
if name in await self.graphs():
if if_not_exists:
return Result([], query_type="s")
raise BoltFailure("Neo.ClientError.Database.ExistingDatabaseFound",
f"Failed to create the specified database '{name}': Database already exists.")
await self._sql("SELECT ag_catalog.create_graph(%s)", [name])
return Result([], query_type="s")
async def _drop_graph(self, name, if_exists):
graphs = {g.lower(): g for g in await self.graphs()}
if name.lower() not in graphs:
if if_exists:
return Result([], query_type="s")
raise BoltFailure("Neo.ClientError.Database.DatabaseNotFound",
f"Failed to delete the specified database '{name}': Database does not exist.")
await self._sql("SELECT ag_catalog.drop_graph(%s, true)", [graphs[name.lower()]])
return Result([], query_type="s")
async def _labels(self, graph):
rows = await self._sql(
"SELECT l.name, l.kind FROM ag_catalog.ag_label l "
"JOIN ag_catalog.ag_graph g ON l.graph = g.graphid "
"WHERE g.name = %s AND l.name <> ALL(%s) ORDER BY l.name",
[graph, list(SYSTEM_LABELS)])
return [r[0] for r in rows if r[1] == "v"], [r[0] for r in rows if r[1] == "e"]
async def _cypher_values(self, graph, query):
rows = await self._sql(
f"SELECT * FROM ag_catalog.cypher({sql.Literal(graph).as_string(self.conn)}, "
f"$peo$ {query} $peo$) AS (c0 ag_catalog.agtype)")
return [agtype.parse(r[0]) for r in rows]
async def _property_keys(self, graph):
# Sample rather than scan everything: this runs every 20s from the browser.
keys = set()
for q in ("MATCH (n) WITH n LIMIT 1000 UNWIND keys(n) AS k RETURN DISTINCT k",
"MATCH ()-[r]->() WITH r LIMIT 1000 UNWIND keys(r) AS k RETURN DISTINCT k"):
keys.update(await self._cypher_values(graph, q))
return sorted(k for k in keys if isinstance(k, str))
async def _counts(self, graph):
nodes = await self._sql(sql.SQL("SELECT count(*) FROM {}._ag_label_vertex").format(sql.Identifier(graph)))
rels = await self._sql(sql.SQL("SELECT count(*) FROM {}._ag_label_edge").format(sql.Identifier(graph)))
return nodes[0][0], rels[0][0]
async def _schema_visualization(self, graph):
labels, _ = await self._labels(graph)
triples = await self._cypher_values(
graph, "MATCH (a)-[r]->(b) RETURN DISTINCT [label(a), label(r), label(b)]")
node_ids = {}
nodes = []
for label in labels + [t[0] for t in triples] + [t[2] for t in triples]:
if label not in node_ids:
node_ids[label] = -(len(node_ids) + 1)
nodes.append(Node(node_ids[label], [label or "(no label)"],
{"name": label or "(no label)", "indexes": [], "constraints": []}))
rels = [Relationship(-(i + 1), node_ids[a], node_ids[b], t, {"name": t})
for i, (a, t, b) in enumerate(triples)]
return Result(["nodes", "relationships"], [[nodes, rels]])
+372
View File
@@ -0,0 +1,372 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""Bolt protocol server (raw TCP and WebSocket on the same port).
Supports Bolt 4.4 and 5.0-5.4, which covers the neo4j-javascript-driver used
by neo4j-browser as well as current Python/Java drivers and cypher-shell.
"""
import asyncio
import base64
import hashlib
import itertools
import logging
import struct
from . import packstream
from .backend import BoltFailure, Config, Session
from .packstream import Structure
from .web import discovery_document
log = logging.getLogger("peo.bolt")
MAGIC = b"\x60\x60\xB0\x17"
SUPPORTED = [(5, 4), (5, 3), (5, 2), (5, 1), (5, 0), (4, 4)]
SERVER_AGENT = "Neo4j/5.26.0"
HELLO, GOODBYE, RESET, RUN, BEGIN, COMMIT, ROLLBACK = 0x01, 0x02, 0x0F, 0x10, 0x11, 0x12, 0x13
DISCARD, PULL, TELEMETRY, ROUTE, LOGON, LOGOFF = 0x2F, 0x3F, 0x54, 0x66, 0x6A, 0x6B
SUCCESS, RECORD, IGNORED, FAILURE = 0x70, 0x71, 0x7E, 0x7F
_connection_ids = itertools.count(1)
class ConnectionClosed(Exception):
pass
# -- transports -----------------------------------------------------------------
class RawTransport:
def __init__(self, reader, writer, prefix=b""):
self.reader, self.writer, self.buffer = reader, writer, bytearray(prefix)
async def read(self, n):
while len(self.buffer) < n:
chunk = await self.reader.read(65536)
if not chunk:
raise ConnectionClosed()
self.buffer += chunk
data = bytes(self.buffer[:n])
del self.buffer[:n]
return data
async def write(self, data):
self.writer.write(data)
await self.writer.drain()
async def close(self):
self.writer.close()
class WebSocketTransport(RawTransport):
"""Minimal RFC 6455 server: Bolt bytes are carried in binary frames."""
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
async def handshake(self):
request = bytearray(self.buffer)
self.buffer.clear()
while b"\r\n\r\n" not in request:
chunk = await self.reader.read(4096)
if not chunk or len(request) > 65536:
raise ConnectionClosed()
request += chunk
head, _, rest = bytes(request).partition(b"\r\n\r\n")
headers = {}
for line in head.decode("latin-1").split("\r\n")[1:]:
k, _, v = line.partition(":")
headers[k.strip().lower()] = v.strip()
method = head.split(b" ", 1)[0]
key = headers.get("sec-websocket-key")
if method != b"GET" or not key:
await self._plain_http(method, headers)
raise ConnectionClosed()
accept = base64.b64encode(hashlib.sha1((key + self.GUID).encode()).digest()).decode()
self.writer.write(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n"
f"Connection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n").encode())
await self.writer.drain()
self.raw = bytearray(rest)
async def _plain_http(self, method, headers):
"""Answer non-WebSocket requests, e.g. neo4j-browser probing for discovery data."""
cors = (b"Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Headers: *\r\n"
b"Access-Control-Allow-Methods: GET, HEAD, OPTIONS\r\nConnection: close\r\n")
if method == b"OPTIONS":
response = b"HTTP/1.1 204 No Content\r\n" + cors + b"\r\n"
elif method in (b"GET", b"HEAD"):
body = discovery_document(headers, self.writer.get_extra_info("sockname")[1])
response = (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" + cors
+ b"Content-Length: %d\r\n\r\n" % len(body) + (body if method == b"GET" else b""))
else:
response = b"HTTP/1.1 405 Method Not Allowed\r\n" + cors + b"Content-Length: 0\r\n\r\n"
self.writer.write(response)
await self.writer.drain()
async def _raw(self, n):
while len(self.raw) < n:
chunk = await self.reader.read(65536)
if not chunk:
raise ConnectionClosed()
self.raw += chunk
data = bytes(self.raw[:n])
del self.raw[:n]
return data
async def read(self, n):
while len(self.buffer) < n:
b0, b1 = await self._raw(2)
opcode, masked, length = b0 & 0x0F, b1 & 0x80, b1 & 0x7F
if length == 126:
length = struct.unpack(">H", await self._raw(2))[0]
elif length == 127:
length = struct.unpack(">Q", await self._raw(8))[0]
mask = await self._raw(4) if masked else None
payload = await self._raw(length)
if mask:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
if opcode == 0x8:
await self._frame(0x8, payload[:2])
raise ConnectionClosed()
if opcode == 0x9:
await self._frame(0xA, payload)
elif opcode in (0x0, 0x1, 0x2):
self.buffer += payload
data = bytes(self.buffer[:n])
del self.buffer[:n]
return data
async def _frame(self, opcode, payload):
n = len(payload)
if n < 126:
header = struct.pack(">BB", 0x80 | opcode, n)
elif n < 0x10000:
header = struct.pack(">BBH", 0x80 | opcode, 126, n)
else:
header = struct.pack(">BBQ", 0x80 | opcode, 127, n)
self.writer.write(header + payload)
await self.writer.drain()
async def write(self, data):
await self._frame(0x2, data)
# -- protocol -----------------------------------------------------------------------
def negotiate(proposals: bytes):
for i in range(4):
_, span, minor, major = proposals[i * 4:i * 4 + 4]
for version in SUPPORTED:
if version[0] == major and minor - span <= version[1] <= minor:
return version
return None
class BoltConnection:
def __init__(self, transport, config: Config):
self.t = transport
self.config = config
self.id = f"bolt-{next(_connection_ids)}"
self.version = None
self.packer = None
self.session: Session | None = None
self.failed = False
self.routing = {}
self.results = {} # qid -> [Result, next row index]
self.last_qid = -1
self.qids = itertools.count()
async def serve(self):
proposals = await self.t.read(16)
self.version = negotiate(proposals)
if self.version is None:
log.info("%s: no supported Bolt version in %s", self.id, proposals.hex())
await self.t.write(b"\x00\x00\x00\x00")
return
await self.t.write(bytes([0, 0, self.version[1], self.version[0]]))
self.packer = packstream.Packer(element_ids=self.version >= (5, 0))
log.debug("%s: negotiated Bolt %d.%d", self.id, *self.version)
try:
while True:
message = await self.read_message()
if message is None:
continue # NOOP chunk
if not await self.handle(message):
break
finally:
if self.session:
await self.session.close()
async def read_message(self):
data = bytearray()
while True:
size = struct.unpack(">H", await self.t.read(2))[0]
if size == 0:
if not data:
return None
return packstream.unpack(bytes(data))
data += await self.t.read(size)
async def send(self, tag, *fields):
data = self.packer.pack(Structure(tag, list(fields)))
out = bytearray()
for i in range(0, len(data), 0xFFFF):
chunk = data[i:i + 0xFFFF]
out += struct.pack(">H", len(chunk)) + chunk
out += b"\x00\x00"
await self.t.write(bytes(out))
async def success(self, metadata=None):
await self.send(SUCCESS, metadata or {})
async def failure(self, code, message):
log.info("%s: %s: %s", self.id, code, message)
self.failed = True
await self.send(FAILURE, {"code": code, "message": message})
async def handle(self, msg) -> bool:
tag, f = msg.tag, msg.fields
if tag == GOODBYE:
return False
if tag == RESET:
self.failed = False
self.results.clear()
if self.session:
await self.session.reset()
await self.success()
return True
if self.failed:
await self.send(IGNORED)
return True
try:
await self.dispatch(tag, f)
except BoltFailure as e:
self.results.clear()
await self.failure(e.code, e.message)
except Exception as e: # noqa: BLE001 - report anything to the client
log.exception("%s: internal error", self.id)
self.results.clear()
await self.failure("Neo.DatabaseError.General.UnknownError", f"peo internal error: {e}")
return True
async def dispatch(self, tag, f):
if tag == HELLO:
extra = f[0] if f else {}
self.routing = extra.get("routing") or {}
if self.version < (5, 1):
await self.logon(extra)
await self.success({"server": SERVER_AGENT, "connection_id": self.id, "hints": {}})
elif tag == LOGON:
await self.logon(f[0])
await self.success()
elif tag == LOGOFF:
if self.session:
await self.session.close()
self.session = None
await self.success()
elif tag == TELEMETRY:
await self.success()
elif tag == ROUTE:
await self.route(f)
elif tag == RUN:
await self.run(f)
elif tag in (PULL, DISCARD):
await self.pull(f[0] if f else {}, discard=tag == DISCARD)
elif tag == BEGIN:
extra = f[0] if f else {}
await self.require_session().begin(extra.get("db"))
self.results.clear()
await self.success()
elif tag == COMMIT:
await self.require_session().commit()
await self.success({"bookmark": "peo:bookmark"})
elif tag == ROLLBACK:
await self.require_session().rollback()
await self.success()
else:
raise BoltFailure("Neo.ClientError.Request.Invalid", f"Unsupported Bolt message 0x{tag:02X}")
def require_session(self) -> Session:
if self.session is None:
raise BoltFailure("Neo.ClientError.Security.Unauthorized", "Not authenticated")
return self.session
async def logon(self, auth):
if auth.get("scheme") != "basic":
raise BoltFailure("Neo.ClientError.Security.Unauthorized",
"peo requires basic authentication with a PostgreSQL user name and password.")
session = Session(self.config, auth.get("principal", ""))
await session.connect(auth.get("credentials", ""))
self.session = session
async def route(self, f):
extra = f[2] if len(f) > 2 else None
db = extra.get("db") if isinstance(extra, dict) else extra
db = await self.require_session().resolve_db(db)
address = self.routing.get("address") or self.config.advertised_bolt
await self.success({"rt": {"ttl": 300, "db": db, "servers": [
{"addresses": [address], "role": "WRITE"},
{"addresses": [address], "role": "READ"},
{"addresses": [address], "role": "ROUTE"},
]}})
async def run(self, f):
query, params = f[0], f[1] if len(f) > 1 else {}
extra = f[2] if len(f) > 2 else {}
session = self.require_session()
if not session.in_transaction:
self.results.clear()
result = await session.run(query, params, extra.get("db"))
qid = next(self.qids)
self.results[qid] = [result, 0]
self.last_qid = qid
metadata = {"fields": result.fields, "t_first": result.t_first}
if session.in_transaction:
metadata["qid"] = qid
await self.success(metadata)
async def pull(self, extra, discard):
qid = extra.get("qid", -1)
qid = self.last_qid if qid == -1 else qid
if qid not in self.results:
raise BoltFailure("Neo.ClientError.Request.Invalid", "No result available to pull from")
result, index = self.results[qid]
n = extra.get("n", -1)
end = len(result.rows) if n == -1 or discard else min(index + n, len(result.rows))
if not discard:
for row in result.rows[index:end]:
await self.send(RECORD, row)
self.results[qid][1] = end
if end < len(result.rows):
await self.success({"has_more": True})
return
del self.results[qid]
metadata = {"type": result.query_type, "t_last": 0, "db": result.db}
if not self.session.in_transaction:
metadata["bookmark"] = "peo:bookmark"
await self.success(metadata)
async def handle_client(reader, writer, config: Config):
peer = writer.get_extra_info("peername")
try:
first = await reader.readexactly(4)
log.debug("connection from %s starts with %r", peer, first)
if first == MAGIC:
transport = RawTransport(reader, writer)
elif first in (b"GET ", b"HEAD", b"OPTI", b"POST"):
transport = WebSocketTransport(reader, writer, first)
await transport.handshake()
if await transport.read(4) != MAGIC:
return
else:
return
await BoltConnection(transport, config).serve()
except (ConnectionClosed, asyncio.IncompleteReadError, ConnectionResetError, BrokenPipeError):
pass
except Exception: # noqa: BLE001
log.exception("connection from %s failed", peer)
finally:
writer.close()
+238
View File
@@ -0,0 +1,238 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""Light-weight Cypher text analysis needed to run queries through AGE.
AGE's ``cypher()`` SQL function needs the caller to declare the result columns
up front, does not accept Neo4j-style parameter maps reliably, and lacks a few
constructs neo4j-browser relies on. This module works on the query text only:
it masks out strings and comments, finds the final top-level RETURN clause,
and inlines parameters as Cypher literals.
"""
import math
import re
from dataclasses import dataclass
class CypherError(Exception):
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
@dataclass
class Scan:
text: str
masked: str # strings/backticks replaced by 'x', comments by spaces
depth: list # bracket nesting depth for every character
def scan(text: str) -> Scan:
masked = list(text)
depth = [0] * len(text)
level = 0
i, n = 0, len(text)
while i < n:
c = text[i]
if c in "'\"`":
j = i + 1
while j < n and text[j] != c:
j += 2 if text[j] == "\\" and c != "`" else 1
for k in range(i, min(j + 1, n)):
masked[k] = "x"
depth[k] = level
i = j + 1
continue
if text.startswith("//", i):
j = text.find("\n", i)
j = n if j == -1 else j
for k in range(i, j):
masked[k] = " "
depth[k] = level
i = j
continue
if text.startswith("/*", i):
j = text.find("*/", i + 2)
j = n if j == -1 else j + 2
for k in range(i, j):
masked[k] = " "
depth[k] = level
i = j
continue
if c in "([{":
depth[i] = level
level += 1
elif c in ")]}":
level = max(level - 1, 0)
depth[i] = level
else:
depth[i] = level
i += 1
return Scan(text, "".join(masked), depth)
def _top_level(s: Scan, pattern, start=0, end=None):
end = len(s.text) if end is None else end
for m in re.finditer(pattern, s.masked[start:end], re.IGNORECASE):
if s.depth[start + m.start()] == 0:
yield start + m.start(), start + m.end()
def strip_statement(text: str) -> str:
text = text.strip()
while text.endswith(";"):
text = text[:-1].rstrip()
return text
def return_columns(text: str) -> list:
"""Column names of the final top-level RETURN clause ([] if there is none)."""
s = scan(text)
returns = list(_top_level(s, r"(?<![\w.$])RETURN(?!\w)"))
if not returns:
return []
clause_start = returns[-1][1]
clause_end = len(text)
for start, _ in _top_level(s, r"(?<![\w.$])(ORDER\s+BY|SKIP|LIMIT|UNION)(?!\w)", clause_start):
clause_end = start
break
distinct = re.match(r"\s*DISTINCT(?!\w)", s.masked[clause_start:clause_end], re.IGNORECASE)
if distinct:
clause_start += distinct.end()
items, item_start = [], clause_start
for comma, _ in _top_level(s, ",", clause_start, clause_end):
items.append((item_start, comma))
item_start = comma + 1
items.append((item_start, clause_end))
columns = []
for start, end in items:
expr = text[start:end].strip()
if expr == "*":
raise CypherError("Neo.ClientError.Statement.FeatureNotSupported",
"RETURN * is not supported by peo; list the returned variables explicitly.")
alias = None
for a_start, a_end in _top_level(s, r"(?<![\w.$])AS\s+(`[^`]*`|\w+)\s*$", start, end):
alias = re.match(r"AS\s+(.*)", text[a_start:a_end].strip(), re.IGNORECASE | re.DOTALL).group(1)
if alias is not None:
name = alias.strip()
if name.startswith("`") and name.endswith("`"):
name = name[1:-1].replace("``", "`")
columns.append(name)
else:
columns.append(expr)
return columns
_PARAM = re.compile(r"\$(`(?:[^`]|``)+`|[A-Za-z_][A-Za-z0-9_]*|\d+)")
def inline_parameters(text: str, params: dict) -> str:
"""Replace ``$name`` references with literal Cypher values."""
if "$" not in text:
return text
s = scan(text)
pieces, last, missing = [], 0, []
for m in _PARAM.finditer(s.masked):
name = m.group(1)
if name.startswith("`"):
name = name[1:-1].replace("``", "`")
if name not in params:
missing.append(name)
continue
pieces.append(text[last:m.start()])
pieces.append(literal(params[name]))
last = m.end()
if missing:
raise CypherError("Neo.ClientError.Statement.ParameterMissing",
"Expected parameter(s): " + ", ".join(dict.fromkeys(missing)))
pieces.append(text[last:])
return "".join(pieces)
def _escape_string(v: str) -> str:
out = []
for ch in v:
if ch == "\\":
out.append("\\\\")
elif ch == "'":
out.append("\\'")
elif ch == "\n":
out.append("\\n")
elif ch == "\r":
out.append("\\r")
elif ch == "\t":
out.append("\\t")
elif ord(ch) < 0x20:
out.append(f"\\u{ord(ch):04x}")
else:
out.append(ch)
return "'" + "".join(out) + "'"
def literal(v) -> str:
if v is None:
return "null"
if v is True:
return "true"
if v is False:
return "false"
if isinstance(v, int):
return str(v)
if isinstance(v, float):
if math.isnan(v):
return "'NaN'::float"
if math.isinf(v):
return "'Infinity'::float" if v > 0 else "'-Infinity'::float"
return repr(v)
if isinstance(v, str):
return _escape_string(v)
if isinstance(v, (list, tuple)):
return "[" + ", ".join(literal(x) for x in v) + "]"
if isinstance(v, dict):
return "{" + ", ".join(
"`" + str(k).replace("`", "``") + "`: " + literal(x) for k, x in v.items()) + "}"
raise CypherError("Neo.ClientError.Statement.TypeError",
f"Parameters of type {type(v).__name__} are not supported by peo")
# neo4j-browser's "expand node" query uses a pattern comprehension, which AGE
# does not support. Rewrite it into an equivalent OPTIONAL MATCH + count.
_NEIGHBOUR_COUNT = re.compile(
r"WITH\s+(\w+)\s*,\s*size\(\s*\[\s*\(\s*\1\s*\)\s*--\s*\(\s*\)\s*\|\s*1\s*\]\s*\)\s+AS\s+(\w+)",
re.IGNORECASE)
def rewrite_for_age(text: str) -> str:
return _NEIGHBOUR_COUNT.sub(
lambda m: f"OPTIONAL MATCH ({m.group(1)})-[peo_r]-() "
f"WITH {m.group(1)}, count(peo_r) AS {m.group(2)}",
text)
# AGE fails with "not a common type" on `id(x) IN [a, b, ...]` when the list has
# more than one element, which neo4j-browser uses to fetch relationships between
# displayed nodes. Expand such lists of integer literals into an OR chain.
_ID_IN_LIST = re.compile(r"(?<![\w.])(id\(\s*\w+\s*\))\s+IN\s+\[([\s\d,+\-]*)\]", re.IGNORECASE)
def expand_id_in_lists(text: str) -> str:
s = scan(text)
pieces, last = [], 0
for m in _ID_IN_LIST.finditer(s.masked):
values = [v.strip() for v in m.group(2).split(",") if v.strip()]
if len(values) == 1:
continue
expr = text[m.start(1):m.end(1)]
pieces.append(text[last:m.start()])
pieces.append("(" + " OR ".join(f"{expr} = {v}" for v in values) + ")" if values else "false")
last = m.end()
pieces.append(text[last:])
return "".join(pieces)
def first_keyword(text: str) -> str:
m = re.match(r"\s*(\w+)", scan(text).masked)
return m.group(1).upper() if m else ""
+243
View File
@@ -0,0 +1,243 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""PackStream v1 serialisation, the binary value format used by Bolt."""
import struct
from dataclasses import dataclass, field
@dataclass
class Structure:
tag: int
fields: list = field(default_factory=list)
@dataclass
class Node:
id: int
labels: list
properties: dict
@dataclass
class Relationship:
id: int
start: int
end: int
type: str
properties: dict
@dataclass
class Path:
nodes: list # alternating sequence is [n0, r0, n1, r1, ..., nk]
relationships: list
class PackStreamError(Exception):
pass
class Packer:
"""Encodes Python values. ``element_ids`` selects the Bolt 5 graph struct layout."""
def __init__(self, element_ids: bool = True):
self.element_ids = element_ids
self.out = bytearray()
def pack(self, value) -> bytes:
self.out = bytearray()
self._pack(value)
return bytes(self.out)
def _pack(self, v):
out = self.out
if v is None:
out.append(0xC0)
elif v is True:
out.append(0xC3)
elif v is False:
out.append(0xC2)
elif isinstance(v, int):
self._pack_int(v)
elif isinstance(v, float):
out.append(0xC1)
out += struct.pack(">d", v)
elif isinstance(v, str):
data = v.encode("utf-8")
self._header(len(data), 0x80, 0xD0, 0xD1, 0xD2)
out += data
elif isinstance(v, (bytes, bytearray)):
n = len(v)
if n < 0x100:
out += bytes([0xCC, n])
elif n < 0x10000:
out.append(0xCD)
out += struct.pack(">H", n)
else:
out.append(0xCE)
out += struct.pack(">I", n)
out += v
elif isinstance(v, (list, tuple)):
self._header(len(v), 0x90, 0xD4, 0xD5, 0xD6)
for item in v:
self._pack(item)
elif isinstance(v, dict):
self._header(len(v), 0xA0, 0xD8, 0xD9, 0xDA)
for k, item in v.items():
self._pack(str(k))
self._pack(item)
elif isinstance(v, Structure):
if len(v.fields) > 15:
raise PackStreamError("structure too large")
out.append(0xB0 + len(v.fields))
out.append(v.tag)
for item in v.fields:
self._pack(item)
elif isinstance(v, Node):
self._pack(self._node(v))
elif isinstance(v, Relationship):
fields = [v.id, v.start, v.end, v.type, v.properties]
if self.element_ids:
fields += [str(v.id), str(v.start), str(v.end)]
self._pack(Structure(0x52, fields))
elif isinstance(v, Path):
self._pack(self._path(v))
else:
raise PackStreamError(f"cannot pack value of type {type(v).__name__}")
def _node(self, n: Node) -> Structure:
fields = [n.id, n.labels, n.properties]
if self.element_ids:
fields.append(str(n.id))
return Structure(0x4E, fields)
def _path(self, p: Path) -> Structure:
nodes, node_index = [], {}
for n in p.nodes:
if n.id not in node_index:
node_index[n.id] = len(nodes)
nodes.append(n)
rels, rel_index = [], {}
for r in p.relationships:
if r.id not in rel_index:
rel_index[r.id] = len(rels)
fields = [r.id, r.type, r.properties]
if self.element_ids:
fields.append(str(r.id))
rels.append(Structure(0x72, fields))
indices = []
prev = p.nodes[0]
for r, n in zip(p.relationships, p.nodes[1:]):
idx = rel_index[r.id] + 1
indices.append(idx if r.start == prev.id else -idx)
indices.append(node_index[n.id])
prev = n
return Structure(0x50, [[self._node(n) for n in nodes], rels, indices])
def _header(self, n, tiny, s8, s16, s32):
out = self.out
if n < 0x10:
out.append(tiny + n)
elif n < 0x100:
out += bytes([s8, n])
elif n < 0x10000:
out.append(s16)
out += struct.pack(">H", n)
else:
out.append(s32)
out += struct.pack(">I", n)
def _pack_int(self, v):
out = self.out
if -0x10 <= v < 0x80:
out += struct.pack(">b", v)
elif -0x80 <= v < 0x80:
out.append(0xC8)
out += struct.pack(">b", v)
elif -0x8000 <= v < 0x8000:
out.append(0xC9)
out += struct.pack(">h", v)
elif -0x80000000 <= v < 0x80000000:
out.append(0xCA)
out += struct.pack(">i", v)
elif -0x8000000000000000 <= v < 0x8000000000000000:
out.append(0xCB)
out += struct.pack(">q", v)
else:
raise PackStreamError("integer out of 64-bit range")
class Unpacker:
def __init__(self, data: bytes):
self.data = data
self.pos = 0
def _take(self, n):
if self.pos + n > len(self.data):
raise PackStreamError("unexpected end of data")
chunk = self.data[self.pos:self.pos + n]
self.pos += n
return chunk
def _u(self, fmt, n):
return struct.unpack(fmt, self._take(n))[0]
def unpack(self):
marker = self._take(1)[0]
if marker < 0x80:
return marker
if marker >= 0xF0:
return marker - 0x100
hi = marker & 0xF0
if hi == 0x80:
return self._take(marker & 0x0F).decode("utf-8")
if hi == 0x90:
return [self.unpack() for _ in range(marker & 0x0F)]
if hi == 0xA0:
return self._map(marker & 0x0F)
if hi == 0xB0:
tag = self._take(1)[0]
return Structure(tag, [self.unpack() for _ in range(marker & 0x0F)])
if marker == 0xC0:
return None
if marker == 0xC1:
return self._u(">d", 8)
if marker == 0xC2:
return False
if marker == 0xC3:
return True
if marker == 0xC8:
return self._u(">b", 1)
if marker == 0xC9:
return self._u(">h", 2)
if marker == 0xCA:
return self._u(">i", 4)
if marker == 0xCB:
return self._u(">q", 8)
if marker in (0xCC, 0xCD, 0xCE):
n = {0xCC: lambda: self._u(">B", 1), 0xCD: lambda: self._u(">H", 2),
0xCE: lambda: self._u(">I", 4)}[marker]()
return bytes(self._take(n))
if marker in (0xD0, 0xD1, 0xD2):
return self._take(self._size(marker - 0xD0)).decode("utf-8")
if marker in (0xD4, 0xD5, 0xD6):
return [self.unpack() for _ in range(self._size(marker - 0xD4))]
if marker in (0xD8, 0xD9, 0xDA):
return self._map(self._size(marker - 0xD8))
raise PackStreamError(f"unknown marker 0x{marker:02X}")
def _size(self, width_code):
return [lambda: self._u(">B", 1), lambda: self._u(">H", 2),
lambda: self._u(">I", 4)][width_code]()
def _map(self, n):
result = {}
for _ in range(n):
key = self.unpack()
result[key] = self.unpack()
return result
def unpack(data: bytes):
return Unpacker(data).unpack()
+86
View File
@@ -0,0 +1,86 @@
# SPDX-FileCopyrightText: 2026 Bart Broere
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tiny HTTP server for the neo4j-browser static files and Neo4j discovery."""
import asyncio
import json
import logging
import mimetypes
import os
from pathlib import Path
from urllib.parse import unquote, urlsplit
from .backend import NEO4J_VERSION
log = logging.getLogger("peo.http")
class HttpServer:
def __init__(self, browser_dir: str, bolt_port: int):
self.root = Path(browser_dir).resolve()
self.bolt_port = bolt_port
async def handle(self, reader, writer):
try:
request = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=30)
lines = request.decode("latin-1").split("\r\n")
method, target, _ = (lines[0].split(" ") + ["", "", ""])[:3]
headers = {}
for line in lines[1:]:
k, _, v = line.partition(":")
headers[k.strip().lower()] = v.strip()
status, extra, body = self.respond(method, target, headers)
head = [f"HTTP/1.1 {status}", f"Content-Length: {len(body)}", "Connection: close",
"X-Content-Type-Options: nosniff"]
head += [f"{k}: {v}" for k, v in extra.items()]
writer.write(("\r\n".join(head) + "\r\n\r\n").encode("latin-1"))
if method != "HEAD":
writer.write(body)
await writer.drain()
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError,
ConnectionResetError, BrokenPipeError):
pass
except Exception: # noqa: BLE001
log.exception("HTTP request failed")
finally:
writer.close()
def respond(self, method, target, headers):
if method not in ("GET", "HEAD"):
return "405 Method Not Allowed", {"Allow": "GET, HEAD"}, b""
path = unquote(urlsplit(target).path)
if path in ("/", ""):
if "text/html" in headers.get("accept", ""):
return "302 Found", {"Location": "/browser/"}, b""
return "200 OK", {"Content-Type": "application/json"}, discovery_document(headers, self.bolt_port)
if path == "/browser":
return "301 Moved Permanently", {"Location": "/browser/"}, b""
if not path.startswith("/browser/"):
return "404 Not Found", {"Content-Type": "text/plain"}, b"Not found\n"
file = (self.root / path[len("/browser/"):]).resolve()
if file.is_dir():
file = file / "index.html"
if not file.is_relative_to(self.root) or not file.is_file():
return "404 Not Found", {"Content-Type": "text/plain"}, b"Not found\n"
ctype = mimetypes.guess_type(file.name)[0] or "application/octet-stream"
cache = "no-cache" if file.name.endswith(".html") else "public, max-age=86400"
return "200 OK", {"Content-Type": ctype, "Cache-Control": cache}, file.read_bytes()
def discovery_document(headers, bolt_port) -> bytes:
"""The JSON Neo4j serves at ``/`` so clients can find the Bolt endpoint."""
host = headers.get("host") or "localhost"
if host.startswith("["): # IPv6 literal, e.g. [::1]:7474
host = host[:host.index("]") + 1]
else:
host = host.split(":", 1)[0]
port = os.environ.get("PEO_ADVERTISED_BOLT_PORT", str(bolt_port))
return json.dumps({
"bolt_routing": f"neo4j://{host}:{port}",
"bolt_direct": f"bolt://{host}:{port}",
"neo4j_version": NEO4J_VERSION,
"neo4j_edition": "community",
}).encode()