Skip to content

Commit

Permalink
feat(celest): Add request context (#176)
Browse files Browse the repository at this point in the history
Adds a context object which is unique per-request and holds request-specific information. The context will also serve as the global service locator for DI.
  • Loading branch information
dnys1 committed Sep 29, 2024
1 parent 3840205 commit 85de3dc
Show file tree
Hide file tree
Showing 10 changed files with 734 additions and 340 deletions.
2 changes: 2 additions & 0 deletions packages/celest/lib/celest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
library celest;

export 'package:celest_core/celest_core.dart';
export 'package:shelf/shelf.dart' show Request, Response;

/// Auth
export 'src/auth/auth.dart';
Expand All @@ -14,6 +15,7 @@ export 'src/config/env.dart';
export 'src/core/annotations.dart';
export 'src/core/cloud_widget.dart';
export 'src/core/context.dart';
export 'src/core/environment.dart';
export 'src/core/project.dart';

/// Functions
Expand Down
33 changes: 33 additions & 0 deletions packages/celest/lib/src/core/annotations.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:celest/celest.dart';
import 'package:meta/meta_meta.dart';

/// Marks a function or library as a cloud API.
Expand Down Expand Up @@ -41,3 +42,35 @@ const customOverride = _CustomOverride();
final class _CustomOverride {
const _CustomOverride();
}

/// {@template celest.core.principal}
/// A contextual reference to the principal ([User]) invoking a [CloudFunction].
///
/// For more information, see [Authorizing your functions](https://celest.dev/docs/functions/authorizing-functions).
///
/// ## Example
///
/// To inject a user into an `@authenticated` function:
///
/// ```dart
/// @authenticated
/// Future<void> sayHello({
/// @principal required User user,
/// }) async {
/// print('Hello, ${user.displayName}!');
/// }
/// ```
///
/// If a user is injected to a `@public` or private function, then the
/// user parameter must be nullable:
///
/// ```dart
/// @public
/// Future<void> sayHello({
/// @principal User? user,
/// }) async {
/// print('Hello, ${user?.displayName ?? 'stranger'}!');
/// }
/// ```
/// {@endtemplate}
const principal = ContextKey.principal;
217 changes: 178 additions & 39 deletions packages/celest/lib/src/core/context.dart
Original file line number Diff line number Diff line change
@@ -1,48 +1,187 @@
import 'dart:async';
import 'dart:io';

import 'package:celest/celest.dart';
import 'package:celest/src/runtime/gcp/gcp.dart';
import 'package:celest_core/_internal.dart';
import 'package:cloud_http/cloud_http.dart';
import 'package:meta/meta.dart';
import 'package:shelf/shelf.dart' as shelf;

/// {@template celest.core.principal}
/// A contextual reference to the principal ([User]) invoking a [CloudFunction].
///
/// For more information, see [Authorizing your functions](https://celest.dev/docs/functions/authorizing-functions).
///
/// ## Example
///
/// To inject a user into an `@authenticated` function:
///
/// ```dart
/// @authenticated
/// Future<void> sayHello({
/// @principal required User user,
/// }) async {
/// print('Hello, ${user.displayName}!');
/// }
/// ```
///
/// If a user is injected to a `@public` or private function, then the
/// user parameter must be nullable:
///
/// ```dart
/// @public
/// Future<void> sayHello({
/// @principal User? user,
/// }) async {
/// print('Hello, ${user?.displayName ?? 'stranger'}!');
/// }
/// ```
/// {@endtemplate}
const principal = _UserContext();
/// The [Context] for the current request.
Context get context => Context.current;

/// {@template celest.core.context}
/// The context of a [CloudFunction] invocation.
/// {@template celest.runtime.celest_context}
/// A per-request context object which propogates request information and common accessors to the Celest server environment.
/// {@endtemplate}
final class Context {
const Context._();
/// {@macro celest.runtime.celest_context}
Context._(this._zone);

/// The [Context] for the given [zone].
factory Context.of(Zone zone) {
return _contexts[zone] ??= Context._(zone);
}

/// All context objects by their [Zone].
///
/// Contexts are attached to a zone such that they are disposed
/// when the Zone in which they were created is disposed.
static final Expando<Context> _contexts = Expando('contexts');

/// The root [Context].
static final Context root = Context.of(Zone.root);

/// The [Context] for the current execution scope.
static Context get current => Context.of(Zone.current);

/// Context-specific values.
final Map<ContextKey<Object>, Object> _values = {};

/// The zone in which this context was created.
final Zone _zone;

/// Retrieves the value in this context for the given [key].
Object? operator [](ContextKey<Object> key) {
return _values[key];
}

/// Sets the value of the given [key] in this context.
void operator []=(ContextKey<Object> key, Object? value) {
if (value == null) {
_values.remove(key);
} else {
_values[key] = value;
}
}

/// The parent [Context] to `this`.
Context? get parent {
var parent = _zone.parent;
while (parent != null) {
if (_contexts[parent] case final parentContext?) {
return parentContext;
}
parent = parent.parent;
}
return null;
}

/// Whether Celest is running in the cloud.
bool get isRunningInCloud => root.get(googleCloudProjectKey) != null;

/// The shelf [shelf.Request] object which triggered the current function invocation.
shelf.Request get currentRequest => expect(ContextKey.currentRequest);

/// The [Traceparent] for the current request.
Traceparent get currentTrace => expect(ContextKey.currentTrace);

/// The Celest [Environment] of the running service.
Environment get environment {
return Environment(Platform.environment['ENV']!);
}

(Context, V)? _get<V extends Object>(ContextKey<V> key) {
if (key.read(this) case final value?) {
return (this, value);
}
return parent?._get(key);
}

/// The value for the given [key] in the current [Context].
V? get<V extends Object>(ContextKey<V> key) {
return _get(key)?.$2;
}

/// Expects a value present in the given [context].
///
/// Throws a [StateError] if the value is not present.
V expect<V extends Object>(ContextKey<V> key) {
final value = get(key);
if (value == null) {
throw StateError('Expected value for key "$key" in context');
}
return value;
}

/// Sets the value of [key] in the current [Context].
void put<V extends Object>(ContextKey<V> key, V value) {
key.set(this, value);
}

/// Updates the value of [key] in place.
void update<V extends Object>(
ContextKey<V> key,
V Function(V? value) update,
) {
final (context, value) = _get(key) ?? (this, null);
final updated = update(value);
context.put(key, updated);
}
}

/// {@template celest.runtime.context_key}
/// A key for a typed value stored in a [Context].
/// {@endtemplate}
@immutable
abstract interface class ContextKey<V extends Object> {
/// {@macro celest.runtime.context_key}
const factory ContextKey([String? label]) = _ContextKey<V>;

/// The context key for the current [shelf.Request].
static const ContextKey<shelf.Request> currentRequest =
ContextKey('current request');

/// The context key for the current [Traceparent].
static const ContextKey<Traceparent> currentTrace =
ContextKey('current trace');

/// The context key for the current [User].
static const ContextKey<User> principal = _PrincipalContextKey();

/// Reads the value for `this` from the given [context].
V? read(Context context);

/// Sets the value for `this` in the given [context].
void set(Context context, V? value);
}

final class _ContextKey<V extends Object> implements ContextKey<V> {
const _ContextKey([this.label]);

final String? label;

@override
V? read(Context context) {
return context[this] as V?;
}

@override
void set(Context context, V? value) {
context[this] = value;
}

@override
bool operator ==(Object other) {
return identical(this, other) ||
other is _ContextKey<V> && other.label == label;
}

@override
int get hashCode => Object.hash(_ContextKey<V>, label);

/// {@macro celest.core.principal}
@Deprecated('Use @principal instead.')
static const Context user = principal;
@override
String toString() {
if (label case final label?) {
return label;
}
if (kDebugMode || !context.environment.isProduction) {
return '<$V>';
}
return '<removed>';
}
}

final class _UserContext implements Context {
const _UserContext();
final class _PrincipalContextKey extends _ContextKey<User> {
const _PrincipalContextKey() : super('principal');
}
18 changes: 18 additions & 0 deletions packages/celest/lib/src/core/environment.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// An environment of a deployed Celest service.
///
/// Celest services can have multiple isolated branches, for example
/// a `development` and `production` environment.
extension type const Environment(String _env) implements String {
/// The local Celest environment, used to delineate when a
/// Celest service is running on a developer machine as opposed
/// to the cloud.
static const Environment local = Environment('local');

/// The production Celest environment which is common to all
/// Celest projects and labels the environment which is considered
/// live and served to end-users.
static const Environment production = Environment('production');

/// Whether `this` represents the production environment.
bool get isProduction => this == production;
}
19 changes: 19 additions & 0 deletions packages/celest/lib/src/runtime/gcp/gcp.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@internal
library;

import 'package:celest/celest.dart';
import 'package:google_cloud/google_cloud.dart';
import 'package:meta/meta.dart';

/// The context key for the active GCP project ID.
const ContextKey<String> googleCloudProjectKey = ContextKey();

/// Returns the GCP project ID for the active environment or
/// `null` if running locally.
Future<String?> googleCloudProject() async {
try {
return await computeProjectId();
} on Exception {
return null;
}
}
Loading

0 comments on commit 85de3dc

Please sign in to comment.