Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Common Commands

### Development

```bash
# Get dependencies
dart pub get

# Run tests
dart test

# Run specific test file
dart test test/path/to/test_file.dart

# Analyze code
dart analyze

# Format code
dart format .

# Format specific files
dart format lib/ test/
```

### Example Application

```bash
# Navigate to example directory
cd example/

# Create database tables
dart run tools/create_database.dart

# Start production server (port 8080)
dart run bin/server.dart

# Start development server with hot reload
dart run --enable-vm-service bin/dev.dart
```

## Architecture Overview

Top Shelf is a Dart package providing helpers, middleware, and architectural patterns for `shelf` HTTP servers. It implements clean architecture with distinct layers:

### Core Layers

- **Handlers/Routes**: HTTP endpoint implementations
- **Services**: Business logic layer with validation hooks (`BaseService<T, ID>`)
- **Repositories**: Data access layer (`CrudRepository<T, ID>`, SQLite implementations)
- **Middleware**: Cross-cutting concerns (auth, logging, CORS, validation, etc.)

### Key Services

- **Account Service**: User management with PBKDF2 password hashing and role-based access
- **Authentication Service**: JWT-based auth with login/refresh token flow
- **Repository Pattern**: Generic CRUD operations with SQLite3 backend

### Important Patterns

- **Dependency Injection**: Services provided via `provide<T>()` middleware
- **Module Organization**: Routes organized by feature and mounted to main router
- **Middleware Pipelines**: Layered middleware application using `Pipeline()`
- **Factory Pattern**: Repository and service factories for clean instantiation

## Key Dependencies

- **shelf/shelf_router**: Core HTTP framework
- **sqlite3**: Database persistence
- **pbkdf2**: Secure password hashing (custom fork)
- **logging**: Structured logging support
- **pointycastle**: Cryptographic operations

## Testing

Tests are organized in `/test/` with patterns for:

- Internal utilities (`/test/internal/`)
- Middleware components (`/test/middlewares/`)
- Service layer (`/test/services/`)

Use `dart test` for full suite or `dart test test/specific_file.dart` for individual test files.

## Security Features

- Password hashing with PBKDF2 + pepper
- JWT authentication with refresh tokens
- Request logging with sensitive data masking
- Input validation at service and middleware levels
- Role-based access control

## In-Memory Repository

For testing and development without database setup, use the in-memory account repository:

```dart
// Create in-memory repository
final repository = AccountMemoryRepository();
final accountService = AccountService(repository, pepperFactory: testPepperFactory);

// Full CRUD operations available
final account = await accountService.createAccount(
email: 'test@example.com',
password: 'password123',
);

// Test utilities
final memoryRepo = repository as AccountMemoryRepository;
print('Total accounts: ${memoryRepo.size}');
memoryRepo.clear(); // Reset for tests
```

**Features:**

- Complete AccountRepositoryInterface implementation
- Email uniqueness enforcement
- Role management (add/remove roles)
- Advanced querying with filters, pagination, sorting
- Account statistics and search functionality
- Test utilities (clear, size, accountIds)

## Development Notes

- Example application in `/example/` demonstrates proper usage patterns
- Database setup required via `dart run tools/create_database.dart` in example
- Hot reload available in development mode
- In-memory repository available for testing: `AccountMemoryRepository()`
- Wiki documentation at: https://github.com/KLEAK-Development/top_shelf/wiki
25 changes: 20 additions & 5 deletions example/lib/src/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,29 @@ Handler _getRouter() {
..mount(
'/authentication',
Pipeline()
.addMiddleware(provide<AAccountsRepository>(
(request) => SqliteAccountRepository(request.get<Database>())))
.addMiddleware(
provide<AccountServiceInterface>(
(request) => AccountService(
AccountSqliteRepository(
request.get<Database>(),
),
),
),
)
.addHandler(authenticationModule),
)
..mount(
'/accounts',
Pipeline()
.addMiddleware(provide<AAccountsRepository>(
(request) => SqliteAccountRepository(request.get<Database>())))
.addMiddleware(
provide<AccountServiceInterface>(
(request) => AccountService(
AccountSqliteRepository(
request.get<Database>(),
),
),
),
)
.addHandler(accountsModule()),
)
..mount('/todos', todos)
Expand All @@ -41,7 +55,8 @@ Handler _getRouter() {
logRequests: true,
logResponses: true,
logTiming: true,
// excludePaths: ['/'], // Exclude health check endpoint from logging
logRequestBody: true,
logResponseBody: true,
metadataExtractor: (request) => {
'service': 'top_shelf_example',
'version': '1.0.0',
Expand Down
6 changes: 3 additions & 3 deletions example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@ version: 1.0.0
publish_to: none

environment:
sdk: '>=3.3.0 <4.0.0'
sdk: ">=3.3.0 <4.0.0"

dependencies:
args: ^2.7.0
logging: ^1.1.1
shelf: ^1.4.0
top_shelf:
top_shelf:
path: ../
shelf_hotreload: ^1.4.0
shelf_router: ^1.1.3
sqlite3: ^2.4.3
xml: ^6.2.2


dev_dependencies:
lints: ^4.0.0
test: ^1.16.0
15 changes: 8 additions & 7 deletions lib/src/internal/jwt.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import 'dart:io';

import 'package:crypto/crypto.dart';

String _defaultJwtSecretKeyFactory() {
String defaultJwtSecretKeyFactory() {
final secretKey = Platform.environment['JWT_SECRET_KEY'];
if (secretKey == null) {
throw Exception("JWT_SECRET_KEY environment variable is not set");
Expand All @@ -18,10 +18,10 @@ class JsonWebToken {
Map<String, dynamic> _header = {"alg": "HS256", "typ": "JWT"};
Map<String, dynamic> _payload = {};

JsonWebToken({this.secretKeyFactory = _defaultJwtSecretKeyFactory});
JsonWebToken({this.secretKeyFactory = defaultJwtSecretKeyFactory});

JsonWebToken.parse(String jwt)
: secretKeyFactory = _defaultJwtSecretKeyFactory {
JsonWebToken.parse(String jwt, {String Function()? secretKeyFactory})
: secretKeyFactory = secretKeyFactory ?? defaultJwtSecretKeyFactory {
_jwt = jwt;
final parts = jwt.split('.');

Expand All @@ -32,9 +32,10 @@ class JsonWebToken {
_payload = json.decode(utf8.decode(base64Url.decode(encodedPayload)));
}

String get sub => _payload['sub'];
String get iat => _payload['iat'];
String get exp => _payload['exp'];
String? get sub => _payload['sub'];
String? get iat => _payload['iat'];
String? get exp => _payload['exp'];
String get jwt => _jwt;

Map<String, dynamic> get payload => _payload;

Expand Down
2 changes: 0 additions & 2 deletions lib/src/services/accounts/accounts.dart

This file was deleted.

5 changes: 3 additions & 2 deletions lib/src/services/accounts/models/account.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:top_shelf/top_shelf.dart';

class Account implements NetworkObjectToJson {
class Account implements NetworkObjectToJson, Entity {
@override
final int id;
final String email;
final String password;
Expand Down Expand Up @@ -50,7 +51,7 @@ class Account implements NetworkObjectToJson {
return {
'id': id,
'email': email,
'creationDate': creationDate.toIso8601String(),
'creationDate': creationDate.toUtc().toIso8601String(),
'roles': roles,
};
}
Expand Down
2 changes: 1 addition & 1 deletion lib/src/services/accounts/models/default_roles.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
typedef RolesType = List<String>;

const defaultRoles = ['user'];
const defaultRoles = ['admin', 'user'];
10 changes: 10 additions & 0 deletions lib/src/services/accounts/router/accounts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import 'package:top_shelf/src/services/accounts/routes/create_account/middleware
as create_account;
import 'package:top_shelf/src/services/accounts/routes/create_account/handler.dart'
as create_account;
import 'package:top_shelf/src/services/accounts/routes/change_password/middleware.dart'
as change_password;
import 'package:top_shelf/src/services/accounts/routes/change_password/handler.dart'
as change_password;

Handler accountsModule({List<String> roles = defaultRoles}) {
return Pipeline()
Expand All @@ -18,6 +22,12 @@ Handler accountsModule({List<String> roles = defaultRoles}) {
Pipeline()
.addMiddleware(create_account.middleware())
.addHandler(create_account.handler),
)
..put(
'/change-password',
Pipeline()
.addMiddleware(change_password.middleware())
.addHandler(change_password.handler),
))
.call),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:shelf/shelf.dart';
import 'package:top_shelf/src/services/accounts/models/account.dart';
import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password.dart';
import 'package:top_shelf/top_shelf.dart';

Future<Account> handler(Request request, ChangePassword changePassword) async {
final service = request.get<AccountServiceInterface>();
final currentAccount = request.get<Account>();

final updatedAccount = await service.changePassword(
currentAccount.id,
changePassword.currentPassword,
changePassword.newPassword,
);

return updatedAccount;
}
20 changes: 20 additions & 0 deletions lib/src/services/accounts/routes/change_password/handler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'dart:io';

import 'package:shelf/shelf.dart';
import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password.dart';
import 'package:top_shelf/src/services/accounts/routes/change_password/change_password.dart'
as change_password;
import 'package:top_shelf/top_shelf.dart';

Future<Response> handler(Request request) async {
try {
final object = await change_password.handler(
request,
request.get<ChangePassword>(),
);
return generateResponse(request, object, status: HttpStatus.ok);
} on ServiceValidationException catch (_) {
// TODO: we need better validation error
return Response.badRequest();
}
}
30 changes: 30 additions & 0 deletions lib/src/services/accounts/routes/change_password/middleware.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'dart:io';

import 'package:shelf/shelf.dart';
import 'package:top_shelf/src/middlewares/allowed_content_type.dart';
import 'package:top_shelf/src/middlewares/body_validator.dart';
import 'package:top_shelf/src/middlewares/get_body.dart';
import 'package:top_shelf/src/middlewares/parse_body.dart';
import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password.dart';
import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password_body.dart';
import 'package:top_shelf/src/services/common/middlewares/auth/jwt_auth.dart';

Middleware middleware({String Function()? secretKeyFactory}) => Pipeline()
.addMiddleware(
jwtAuth(secretKeyFactory: secretKeyFactory),
) // Authenticate user via JWT
.addMiddleware(allowedContentType([
ContentType('application', 'json'),
ContentType('application', 'xml'),
ContentType('application', 'x-www-form-urlencoded'),
ContentType('multipart', 'form-data'),
]))
.addMiddleware(getBody((body) => ChangePasswordBody(body),
objectName: 'ChangePassword'))
.addMiddleware(bodyFieldIsRequired<ChangePasswordBody>('currentPassword'))
.addMiddleware(
bodyFieldIsType<ChangePasswordBody, String>('currentPassword'))
.addMiddleware(bodyFieldIsRequired<ChangePasswordBody>('newPassword'))
.addMiddleware(bodyFieldIsType<ChangePasswordBody, String>('newPassword'))
.addMiddleware(parseBody<ChangePassword, ChangePasswordBody>())
.middleware;
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class ChangePassword {
final String currentPassword;
final String newPassword;

const ChangePassword(this.currentPassword, this.newPassword);

factory ChangePassword.fromJson(Map<String, dynamic> json) {
if (json
case {
'currentPassword': final String currentPassword,
'newPassword': final String newPassword
}) {
return ChangePassword(currentPassword, newPassword);
} else {
throw FormatException('Unexpected JSON');
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'package:top_shelf/src/internal/body.dart';
import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password.dart';

class ChangePasswordBody extends Body<ChangePassword> {
ChangePasswordBody(super.data);

@override
ChangePassword parse() => ChangePassword.fromJson(data);
}
Loading