diff --git a/Makefile b/Makefile
index 70d4462..211b85b 100644
--- a/Makefile
+++ b/Makefile
@@ -21,23 +21,20 @@ tf-deploy: ## Deploy the app locally via Terraform
mkdir -p build/lambda
cp -r app/lambda/* build/lambda/
- # docker run -it --platform=linux/amd64 --rm --entrypoint= -v $(PWD)/build/lambda:/tmp/lambda public.ecr.aws/lambda/python:3.11 pip install --upgrade --target /tmp/lambda -r /tmp/lambda/requirements.txt
+ docker run -it --platform=linux/amd64 --rm --entrypoint= -v $(PWD)/build/lambda:/tmp/lambda public.ecr.aws/lambda/python:3.11 pip install --upgrade --target /tmp/lambda -r /tmp/lambda/requirements.txt
- # NOTE: SOMETIMES THE ARM64 VERSION WORKS, SOMETIMES THE AMD64 VERSION WORKS?
+ ##### NOTE: SOMETIMES THE ARM64 VERSION WORKS, SOMETIMES THE AMD64 VERSION WORKS? #####
#docker run -it --platform=linux/arm64/v8 --rm --entrypoint= -v $(PWD)/build/lambda:/tmp/lambda public.ecr.aws/lambda/python:3.11 pip install --upgrade --target /tmp/lambda -r /tmp/lambda/requirements.txt
$(VENV_RUN); tflocal init; tflocal apply -auto-approve
-requests: ## Send a couple of test requests to create entries in the database
- endpoint=http://users-api.execute-api.localhost.localstack.cloud:4566/test/users; \
- curl -H 'content-type: application/json' -d '{"name":"Alice","age":42}' $$endpoint; \
- curl -H 'content-type: application/json' -d '{"name":"Bob","age":31}' $$endpoint; \
- curl $$endpoint
+test-lambda: ## Run Lambda API tests
+ $(VENV_RUN); pytest tests/test_lambda.py -v -s
format: ## Run ruff to format the whole codebase
$(VENV_RUN); python -m ruff format .; python -m ruff check --output-format=full --fix .
test: ## Run integration tests (requires LocalStack running with the Extension installed)
- $(VENV_RUN); pytest tests
+ $(VENV_RUN); pytest tests/test_extension.py -v -s
-.PHONY: clean install usage venv format test
+.PHONY: clean install usage venv format test requests test-lambda tf-deploy
diff --git a/README.md b/README.md
index 3ad3054..afc89de 100644
--- a/README.md
+++ b/README.md
@@ -19,10 +19,12 @@ $ localstack extensions install "git+https://github.com/whummer/localstack-utils
## Start localstack
```
-$ localstack start
+$ DOCKER_FLAGS='-e TYPEDB_FLAGS=--development-mode.enabled=true' localstack start
```
-Note: mac users may need to also run
+(Note: developers of this repo should set the development mode flag to true to disable TypeDB's analytics)
+
+Mac users may need to also run
```
$ sudo /Applications/Docker.app/Contents/MacOS/install vmnetd
```
diff --git a/app/lambda/handler.py b/app/lambda/handler.py
index b7a3241..5ab687b 100644
--- a/app/lambda/handler.py
+++ b/app/lambda/handler.py
@@ -1,52 +1,414 @@
import json
+import sys
+import time
+import logging
-from typedb.driver import TypeDB, Credentials, DriverOptions, TransactionType
+from typedb.driver import TypeDB, Credentials, DriverOptions, TransactionType, TransactionOptions
+
+# Setup TRACE logger
+logger = logging.getLogger(__name__)
+logger.setLevel(logging.INFO)
db_name = "test-db"
server_host = "typedb.localhost.localstack.cloud:4566"
+def _transaction_options():
+ """Get transaction options with configured timeout"""
+ return TransactionOptions(transaction_timeout_millis=10_000)
+
+def _cors_response(status_code, body):
+ """Create a response with CORS headers"""
+ return {
+ "statusCode": status_code,
+ "headers": {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
+ },
+ "body": json.dumps(body) if isinstance(body, dict) else body
+ }
+
def handler(event, context):
+ logger.debug(f"Lambda invoked with event: {json.dumps(event, default=str)}")
+
_create_database_and_schema()
method = event["httpMethod"]
- result = {}
- if method == "GET":
- result = list_users()
- elif method == "POST":
- payload = json.loads(event["body"])
- result = create_user(payload)
- return {"statusCode": 200, "body": json.dumps(result)}
+ path = event.get("path", "")
+
+ logger.debug(f"Processing {method} request to {path}")
+
+ # Handle CORS preflight requests
+ if method == "OPTIONS":
+ return _cors_response(200, "")
+
+ try:
+ # Route based on path and method
+ if path == "/users":
+ if method == "GET":
+ result = list_users()
+ return _cors_response(200, result)
+ elif method == "POST":
+ payload = json.loads(event["body"])
+ result = create_user(payload)
+ return _cors_response(201, result)
+ elif path == "/groups":
+ if method == "GET":
+ result = list_groups()
+ return _cors_response(200, result)
+ elif method == "POST":
+ payload = json.loads(event["body"])
+ result = create_group(payload)
+ return _cors_response(201, result)
+ elif path.startswith("/groups/") and path.endswith("/members"):
+ # Extract group_name from path like /groups/{group_name}/members
+ group_name = path.split("/")[2]
+ if method == "GET":
+ result = list_direct_group_members(group_name)
+ return _cors_response(200, result)
+ elif method == "POST":
+ payload = json.loads(event["body"])
+ result = add_member_to_group(group_name, payload)
+ return _cors_response(201, result)
+ elif path.startswith("/groups/") and path.endswith("/all-members"):
+ # Extract group_name from path like /groups/{group_name}/all-members
+ group_name = path.split("/")[2]
+ if method == "GET":
+ result = list_all_group_members(group_name)
+ return _cors_response(200, result)
+ elif path.startswith("/users/") and path.endswith("/groups"):
+ # Extract username from path like /users/{username}/groups
+ username = path.split("/")[2]
+ if method == "GET":
+ result = list_principal_groups(username, "user")
+ return _cors_response(200, result)
+ elif path.startswith("/users/") and path.endswith("/all-groups"):
+ # Extract username from path like /users/{username}/all-groups
+ username = path.split("/")[2]
+ if method == "GET":
+ result = list_all_principal_groups(username, "user")
+ return _cors_response(200, result)
+ elif path.startswith("/groups/") and path.endswith("/groups"):
+ # Extract group_name from path like /groups/{group_name}/groups
+ group_name = path.split("/")[2]
+ if method == "GET":
+ result = list_principal_groups(group_name, "group")
+ return _cors_response(200, result)
+ elif path.startswith("/groups/") and path.endswith("/all-groups"):
+ # Extract group_name from path like /groups/{group_name}/all-groups
+ group_name = path.split("/")[2]
+ if method == "GET":
+ result = list_all_principal_groups(group_name, "group")
+ return _cors_response(200, result)
+ elif path == "/reset":
+ if method == "POST":
+ result = reset_database()
+ return _cors_response(200, result)
+
+ logger.debug(f"No route found for {method} request to {path}")
+ return _cors_response(404, {"error": "Not found"})
+ except Exception as e:
+ logger.debug(f"Error processing request: {str(e)}")
+ return _cors_response(400, {"error": str(e)})
def create_user(payload: dict):
- user_name = payload["name"]
- user_age = payload["age"]
+ logger.debug(f"Creating user")
+ # Validate required fields
+ if "username" not in payload:
+ raise ValueError("Username is required")
+ if "email" not in payload or not payload["email"]:
+ raise ValueError("At least one email is required")
+
+ username = payload["username"]
+ emails = payload["email"]
+
+ # Ensure emails is a list
+ if isinstance(emails, str):
+ emails = [emails]
+
+ # Optional fields
+ profile_picture_uri = payload.get("profile_picture_uri", "")
+
+ try:
+ with _driver() as driver:
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ # Create user with username
+ query = f"insert $u isa user, has user-name '{username}'"
+
+ # Add all emails
+ for email in emails:
+ query += f", has email '{email}'"
+
+ # Add profile picture if provided - check if it's HTTP URL or S3 identifier
+ if profile_picture_uri:
+ if profile_picture_uri.startswith("http"):
+ query += f", has profile-picture-url '{profile_picture_uri}'"
+ else:
+ query += f", has profile-picture-s3-uri '{profile_picture_uri}'"
+
+ query += ";"
+
+ tx.query(query).resolve()
+ tx.commit()
+
+ return {"message": "User created successfully", "username": username, "email": emails}
+
+ except Exception as e:
+ error_msg = str(e)
+ if "DVL9" in error_msg and "key constraint violation" in error_msg:
+ raise ValueError(f"User '{username}' already exists")
+ else:
+ raise e
+
+
+def create_group(payload: dict):
+ logger.debug(f"Creating group")
+ # Validate required fields
+ if "group_name" not in payload:
+ raise ValueError("Group name is required")
+
+ group_name = payload["group_name"]
+
+ try:
+ with _driver() as driver:
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ # Create group with group name
+ query = f"insert $g isa group, has group-name '{group_name}';"
+
+ tx.query(query).resolve()
+ tx.commit()
+
+ return {"message": "Group created successfully", "group_name": group_name}
+
+ except Exception as e:
+ error_msg = str(e)
+ if "DVL9" in error_msg and "key constraint violation" in error_msg:
+ raise ValueError(f"Group '{group_name}' already exists")
+ else:
+ raise e
+
+
+def list_users():
+ logger.debug("Listing users")
with _driver() as driver:
- with driver.transaction(db_name, TransactionType.WRITE) as tx:
- query = f"insert $p isa person, has name '{user_name}', has age {user_age};"
+ logger.debug("Listing users - opened driver")
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ logger.debug("Listing users - opened transaction")
+ result = tx.query(
+ 'match $u isa user; '
+ 'fetch {'
+ ' "username": $u.user-name, '
+ ' "email": [$u.email], '
+ ' "profile_picture_url": $u.profile-picture-url, '
+ ' "profile_picture_s3_uri": $u.profile-picture-s3-uri'
+ '};'
+ ).resolve()
+ result = list(result)
+ tx.commit()
+
+ return result
+
+
+def list_groups():
+ logger.debug("Listing groups")
+ with _driver() as driver:
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ result = tx.query(
+ 'match $g isa group; '
+ 'fetch {'
+ ' "group_name": $g.group-name'
+ '};'
+ ).resolve()
+ result = list(result)
+ tx.commit()
+
+ return result
+
+
+def add_member_to_group(group_name: str, payload: dict):
+ logger.debug(f"Adding member to group")
+
+ # Validate required fields - either username or group_name must be provided
+ if "username" not in payload and "group_name" not in payload:
+ raise ValueError("Either 'username' or 'group_name' is required")
+
+ if "username" in payload and "group_name" in payload:
+ raise ValueError("Provide either 'username' or 'group_name', not both")
+
+ with _driver() as driver:
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ if "username" in payload:
+ # Adding a user to the group
+ username = payload["username"]
+ query = (
+ f"match "
+ f" $member isa user, has user-name '{username}'; "
+ f" $group isa group, has group-name '{group_name}'; "
+ f"put "
+ f" $membership isa membership (container: $group, member: $member);"
+ )
+ member_type = "user"
+ member_name = username
+ else:
+ # Adding a group to the group
+ member_group_name = payload["group_name"]
+ query = (
+ f"match "
+ f" $member isa group, has group-name '{member_group_name}'; "
+ f" $group isa group, has group-name '{group_name}'; "
+ f"put "
+ f" $membership isa membership (container: $group, member: $member);"
+ )
+ member_type = "group"
+ member_name = member_group_name
+
tx.query(query).resolve()
tx.commit()
+
+ return {
+ "message": f"{member_type.capitalize()} added to group successfully",
+ "group_name": group_name,
+ "member_type": member_type,
+ "member_name": member_name
+ }
-def list_users():
+def list_direct_group_members(group_name: str):
+ logger.debug(f"Listing direct group members for {group_name}")
+ with _driver() as driver:
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ result = tx.query(
+ f'match '
+ f' $group isa group, has group-name "{group_name}"; '
+ f' $membership isa membership (container: $group, member: $member); '
+ f' $member isa! $member-type; '
+ f'fetch {{'
+ f' "member_name": $member.name, '
+ f' "group_name": $group.group-name,'
+ f' "member_type": $member-type'
+ f'}};'
+ ).resolve()
+ result = list(result)
+ tx.commit()
+
+ return result
+
+
+def list_all_group_members(group_name: str):
+ logger.debug(f"Listing all group members for {group_name}")
with _driver() as driver:
- with driver.transaction(db_name, TransactionType.READ) as tx:
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ # Use the group-members function from the schema to get all members recursively
result = tx.query(
- 'match $p isa person; fetch {"name": $p.name, "age": $p.age};'
+ f'match '
+ f' $group isa group, has group-name "{group_name}"; '
+ f' let $members in group-members($group); '
+ f'fetch {{'
+ f' "member_type": $members.isa, '
+ f' "member_name": $members.name, '
+ f' "group_name": $members.group-name'
+ f'}};'
).resolve()
result = list(result)
+ tx.commit()
+
return result
+def list_principal_groups(principal_name: str, principal_type: str):
+ """List direct groups for either a user or group principal"""
+ logger.debug(f"Listing direct groups for {principal_name} of type {principal_type}")
+ with _driver() as driver:
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ if principal_type == "user":
+ name_attr = "user-name"
+ else: # group
+ name_attr = "group-name"
+
+ result = tx.query(
+ f'match '
+ f' $principal isa {principal_type}, has {name_attr} "{principal_name}"; '
+ f' membership (member: $principal, container: $group); '
+ f' $group isa group; '
+ f'fetch {{'
+ f' "group_name": $group.group-name'
+ f'}};'
+ ).resolve()
+ result = list(result)
+ tx.commit()
+
+ return result
+
+
+def list_all_principal_groups(principal_name: str, principal_type: str):
+ """List all groups (transitive) for either a user or group principal"""
+ logger.debug(f"Listing all groups for {principal_name} of type {principal_type}")
+ with _driver() as driver:
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ if principal_type == "user":
+ name_attr = "user-name"
+ else: # group
+ name_attr = "group-name"
+
+ # Use the get-groups function from the schema to get all groups transitively
+ result = tx.query(
+ f'match '
+ f' $principal isa {principal_type}, has {name_attr} "{principal_name}"; '
+ f' let $groups in get-groups($principal); '
+ f'fetch {{'
+ f' "group_name": $groups.group-name'
+ f'}};'
+ ).resolve()
+ result = list(result)
+ tx.commit()
+
+ return result
+
+
+def reset_database():
+ """Reset the database by deleting it and recreating it with schema"""
+ logger.debug("Resetting database")
+ with _driver() as driver:
+ # Delete database if it exists
+ if driver.databases.contains(db_name):
+ driver.databases.get(db_name).delete()
+ logger.debug(f"Database '{db_name}' deleted")
+
+
+ _create_database_and_schema()
+
+ return {"message": "Database reset successfully"}
+
+
def _create_database_and_schema():
+ logger.debug("Setting up database and schema")
with _driver() as driver:
- driver.databases.create(db_name)
- with driver.transaction(db_name, TransactionType.SCHEMA) as tx:
- tx.query("define entity person;").resolve()
- tx.query("define attribute name, value string; person owns name;").resolve()
- tx.query("define attribute age, value integer; person owns age;").resolve()
+ # Check if database exists, create only if it doesn't
+ if db_name not in [db.name for db in driver.databases.all()]:
+ driver.databases.create(db_name)
+ logger.debug(f"Database '{db_name}' created")
+ else:
+ logger.debug(f"Database '{db_name}' already exists")
+
+ entity_type_count = 0
+ # Check if schema already exists by looking for user type
+ with driver.transaction(db_name, TransactionType.WRITE, _transaction_options()) as tx:
+ entity_type_count = len(list(tx.query("match entity $t;").resolve().as_concept_rows()))
tx.commit()
+ if entity_type_count == 0:
+ logger.debug("Loading schema from file")
+ with driver.transaction(db_name, TransactionType.SCHEMA, _transaction_options()) as schema_tx:
+ # Load schema from file
+ with open("schema.tql", "r") as f:
+ schema_content = f.read()
+ schema_tx.query(schema_content).resolve()
+ schema_tx.commit()
+ logger.debug("Schema loaded successfully")
+ else:
+ logger.debug("Schema already exists.")
+
def _driver():
return TypeDB.driver(
diff --git a/app/lambda/schema.tql b/app/lambda/schema.tql
new file mode 100644
index 0000000..f79993b
--- /dev/null
+++ b/app/lambda/schema.tql
@@ -0,0 +1,64 @@
+define
+
+# Abstract entity for any actor in the system (user or group).
+# This allows groups to contain other groups (nesting).
+entity principal @abstract,
+ # Principals have an abstract name
+ owns name,
+ # Any principal (a user or another group) can be a member.
+ plays membership:member;
+
+# Represents a human user.
+entity user sub principal,
+ owns email @unique @card(1..),
+ owns user-name @key,
+
+ # one-of multiple types of profile picture
+ owns profile-picture-uri @card(0..1),
+ owns profile-picture-s3-uri,
+ owns profile-picture-url;
+
+# Represents a collection of users and/or other groups.
+entity group sub principal,
+ owns group-name @key,
+ # A group can act as a container for members.
+ plays membership:container;
+
+# Defines the relationship of a principal being a member of a group.
+relation membership,
+ relates container,
+ relates member;
+
+# --- Attributes ---
+attribute name @abstract, value string;
+attribute user-name, sub name;
+attribute group-name, sub name;
+attribute email, value string;
+
+attribute profile-picture-uri @abstract;
+attribute profile-picture-s3-uri, sub profile-picture-uri, value string;
+attribute profile-picture-url, sub profile-picture-uri, value string;
+
+
+# --- Functions ---
+
+fun group-members($group: group) -> { principal }:
+ match
+ {
+ membership (container: $group, member: $member);
+ } or {
+ membership (container: $group, member: $group-member);
+ $group-member isa group;
+ let $member in group-members($group-member);
+ };
+ return { $member };
+
+fun get-groups($principal: principal) -> { group }:
+ match
+ {
+ membership (member: $principal, container: $group);
+ } or {
+ membership (member: $principal, container: $container);
+ let $group in get-groups($container);
+ };
+ return { $group };
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..8129bda
--- /dev/null
+++ b/index.html
@@ -0,0 +1,139 @@
+
+
+
+
+
+ TypeDB User & Group Management
+
+
+
+
+
+
+
Management
+
+
+
+
+
+
+
+
+
Users
+
+
+
+
+
+
+
+
+
+
+
Groups
+
+
+
+
+
+
+
+
+
+
+
+
Welcome to TypeDB User & Group Management
+
Select a user or group from the sidebar to view details.
+
+
+
+
+
+
+
User Information
+
+
+
+
Direct Groups
+
+
+
+
All Groups (Including Indirect)
+
+
+
+
+
+
+
+
+
Group Information
+
+
+
+
Direct Groups This Group Is A Member Of
+
+
+
+
Direct Groups Within This Group
+
+
+
+
All Groups This Group Is A Member Of (Including Indirect)