Skip to content

Commit

Permalink
xRPC app.arkavo.actor.getProfile (#21)
Browse files Browse the repository at this point in the history
* Add Profile Lambda and migrate project to new structure

Introduced a new Profile Lambda function for retrieving user profiles in compliance with the ATProtocol. Added DynamoDB support via ProfilesTable and updated permissions accordingly. Restructured the project by renaming and reorganizing the directory to better align with Lambda-centric workflows.

* Refactor profile lambda and update deployment docs

Simplified response formatting and error handling in the profile lambda, improving readability and maintainability. Modified routes to remove redundant path prefixes. Updated README with deployment planning steps and renamed it for consistency.
  • Loading branch information
arkavo-com authored Jan 11, 2025
1 parent 946ce9b commit e2b8fe2
Show file tree
Hide file tree
Showing 13 changed files with 455 additions and 1 deletion.
File renamed without changes.
5 changes: 5 additions & 0 deletions handle-resolution-service/README.md → lambdas/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ chmod +x build.sh
sam deploy --guided
```

3. Plan
```bash
sam build && sam deploy --no-execute-changese
```

3. Subsequent deployments:
```bash
sam build && sam deploy --no-confirm-changeset
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
179 changes: 179 additions & 0 deletions lambdas/profile/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# Arkavo Profile Lambda

ATProtocol-compliant Lambda function for retrieving user profile information. This service provides a scalable endpoint for accessing both standard ATProtocol profile fields and Arkavo-specific extensions.

## Overview

The Profile Lambda function serves as an endpoint for retrieving user profile information through the ATProtocol specification. It interfaces with DynamoDB to store and retrieve profile data, handling both standard ATProtocol fields and Arkavo-specific extensions.

### Endpoint Details

- **URL**: `https://xrpc.arkavo.net/xrpc/app.arkavo.actor.getProfile`
- **Method**: GET
- **Query Parameters**:
- `actor` (required): String representing either the DID or handle of the user

## Getting Started

### Prerequisites

- Python 3.9+
- AWS CLI configured with appropriate credentials
- AWS SAM CLI (for local development)

### Installation

1. Clone the repository:
```bash
git clone https://github.com/arkavo/devsecops.git
cd lambdas/profile
```

2. Create a virtual environment and install dependencies:
```bash
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

3. Set up local environment variables:
```bash
cp .env.example .env
# Edit .env with your configuration
```

### Local Development

Run the function locally using SAM CLI:

```bash
sam local invoke -e events/test_event.json
```

Or start a local API endpoint:

```bash
sam local start-api
```

## Deployment

### Using AWS SAM

1. Build the application:
```bash
sam build
```

2. Deploy to AWS:
```bash
sam deploy --guided
```

### Manual Deployment

1. Package the Lambda:
```bash
zip -r function.zip . -x "*.git*" "*.env*" "*.venv*"
```

2. Deploy using AWS CLI:
```bash
aws lambda update-function-code --function-name profile-lambda --zip-file fileb://function.zip
```

## Configuration

### Environment Variables

- `PROFILES_TABLE_NAME`: DynamoDB table name for storing profiles

### DynamoDB Schema

The DynamoDB table requires the following schema:

```json
{
"actor": "string" (Primary Key),
"handle": "string",
"did": "string",
"displayName": "string",
"avatarUrl": "string" (optional),
"description": "string" (optional),
"profileType": "string",
"isVerified": "boolean",
"followersCount": "number",
"followingCount": "number",
"creationDate": "string",
"publicID": "string"
}
```

## API Response Format

### Successful Response

```json
{
"standard": {
"handle": "string",
"did": "string",
"displayName": "string",
"avatarUrl": "string",
"description": "string"
},
"extended": {
"profileType": "string",
"isVerified": boolean,
"followersCount": number,
"followingCount": number,
"creationDate": "string",
"publicID": "string"
}
}
```

### Error Response

```json
{
"error": "Error message"
}
```

## Testing

Run the test suite:

```bash
pytest tests/
```

Run with coverage:

```bash
pytest --cov=src tests/
```

## Monitoring and Logging

The function uses AWS Lambda Powertools for structured logging and tracing. Logs are available in CloudWatch Logs, and traces can be viewed in AWS X-Ray.

### Log Groups

- `/aws/lambda/profile-lambda`: Main function logs
- `/aws/lambda/profile-lambda-dev`: Development environment logs

### Metrics

Key metrics are available in CloudWatch Metrics under the custom namespace `Arkavo/ProfileLambda`:

- `GetProfileLatency`: Response time for profile retrieval
- `ProfileNotFound`: Count of 404 errors
- `DatabaseErrors`: Count of DynamoDB-related errors

## Related Documentation

- [ATProtocol Specification](https://atproto.com/specs/atp)
- [AWS Lambda Powertools](https://awslabs.github.io/aws-lambda-powertools-python/)
-
137 changes: 137 additions & 0 deletions lambdas/profile/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import os
from typing import Dict, Any, Optional

import boto3
from aws_lambda_powertools import Logger, Metrics
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
from aws_lambda_powertools.utilities.typing import LambdaContext
from botocore.exceptions import ClientError

# Initialize utilities
logger = Logger()
metrics = Metrics()
app = APIGatewayRestResolver(strip_prefixes=["/xrpc"])

# Constants
DYNAMO_TABLE_NAME = os.environ["PROFILES_TABLE_NAME"]


class ProfileError(Exception):
"""Custom exception for profile-related errors"""

def __init__(self, message: str, status_code: int = 400):
self.message = message
self.status_code = status_code
super().__init__(self.message)


def get_profile_from_dynamo(actor: str) -> Optional[Dict[str, Any]]:
"""
Retrieve profile from DynamoDB
"""
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(DYNAMO_TABLE_NAME)

try:
# First try as handle (primary key)
response = table.get_item(Key={"handle": actor})
if "Item" in response:
return response["Item"]

# If not found, scan for DID or publicID
response = table.scan(
FilterExpression="did = :did OR publicID = :pid",
ExpressionAttributeValues={
":did": actor,
":pid": actor
}
)
items = response.get("Items", [])
return items[0] if items else None
except ClientError as e:
logger.error(f"DynamoDB error: {str(e)}")
raise ProfileError("Database error", 500)


def validate_profile_data(profile: Dict[str, Any]) -> None:
"""
Validate profile data structure and required fields
"""
required_fields = {
"handle", "did", "profileName", "creationDate", "publicID"
}

missing_fields = required_fields - set(profile.keys())
if missing_fields:
raise ProfileError(f"Missing required fields: {', '.join(missing_fields)}")


def format_profile_response(profile: Dict[str, Any]) -> Dict[str, Any]:
"""
Format profile data as a flat structure
"""
return {
"handle": profile["handle"],
"did": profile["did"],
"displayName": profile["profileName"],
"avatarUrl": profile.get("avatarUrl"),
"description": profile.get("description"),
"creationDate": profile["creationDate"],
"publicID": profile["publicID"]
}


@app.get("/app.arkavo.actor.getProfile")
def get_profile() -> Dict[str, Any]:
"""
Main handler for profile retrieval
"""
try:
# Get and validate query parameters
actor = app.current_event.get_query_string_value(name="actor")
if not actor:
app.response.status_code = 400
return {"error": "Missing 'actor' parameter"}

# Get profile data
profile = get_profile_from_dynamo(actor)
if not profile:
app.response.status_code = 404
return {"error": "Profile not found"}

# Validate profile data
validate_profile_data(profile)

# Record metric for successful lookup
metrics.add_metric(name="ProfileLookupSuccess", unit="Count", value=1)

# Return formatted response
return format_profile_response(profile)

except ProfileError as error:
logger.error(f"Profile error: {error.message}")
metrics.add_metric(name="ProfileLookupError", unit="Count", value=1)
app.response.status_code = error.status_code
return {"error": error.message}
except Exception as e:
logger.exception("Unhandled exception")
metrics.add_metric(name="UnhandledError", unit="Count", value=1)
app.response.status_code = 500
return {"error": "Internal server error"}


@logger.inject_lambda_context
@metrics.log_metrics
def lambda_handler(event: Dict[str, Any], context: LambdaContext) -> Dict[str, Any]:
"""
Lambda handler entry point
"""
return app.resolve(event, context)


if __name__ == "__main__":
# For local testing
test_event = {
"queryStringParameters": {"actor": "test_user"}
}
print(lambda_handler(test_event, None))
Loading

0 comments on commit e2b8fe2

Please sign in to comment.