Skip to content

Commit

Permalink
Support bucket refs in Encore.ts (encoredev#1583)
Browse files Browse the repository at this point in the history
  • Loading branch information
eandre authored Nov 20, 2024
1 parent f8d4162 commit 268e27e
Show file tree
Hide file tree
Showing 14 changed files with 310 additions and 41 deletions.
46 changes: 46 additions & 0 deletions docs/ts/primitives/object-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,49 @@ If an upload fails due to a precondition not being met (like if the object alrea
and the `notExists: true` option is set), it throws a `PreconditionFailed` error.

Other errors are returned as `ObjectsError` errors (which the above errors also extend).

## Bucket references

Encore uses static analysis to determine which services are accessing each bucket,
and what operations each service is performing.

That information is used for features such as rendering architecture diagrams, and can be used by Encore's Cloud Platform to provision infrastructure correctly and configure IAM permissions.

This means `Bucket` objects can't be passed around however you like,
as it makes static analysis impossible in many cases. To simplify your workflow, given these restrictions,
Encore supports defining a "reference" to a bucket that can be passed around any way you want.

### Using bucket references

Define a bucket reference by calling `bucket.ref<DesiredPermissions>()` from within a service, where `DesiredPermissions` is one of the pre-defined permission types defined in the `encore.dev/storage/objects` module.

This means you're effectively pre-declaring the permissions you need, and only the methods that
are allowed by those permissions are available on the returned reference object.

For example, to get a reference to a bucket that can only download objects:

```typescript
import { Uploader } from "encore.dev/storage/objects";
const ref = profilePictures.ref<Uploader>();

// You can now freely pass around `ref`, and you can use
// `ref.upload()` just like you would `profilePictures.upload()`.
```

To ensure Encore still is aware of which permissions each service needs, the call to `bucket.ref`
must be made from within a service, so that Encore knows which service to associate the permissions with.

Encore provides permission interfaces for each operation that can be performed on a bucket:

* `Downloader` for downloading objects
* `Uploader` for uploading objects
* `Lister` for listing objects
* `Attrser` for getting object attributes
* `Remover` for removing objects

If you need multiple permissions you can combine them using `&`.
For example, `profilePictures.ref<Downloader & Uploader>` gives you a reference
that allows calling both `download` and `upload`.

For convenience Encore also provides a `ReadWriter` permission that gives complete read-write access
to the bucket, granting all the permissions above. It is equivalent to `Downloader & Uploader & Lister & Attrser & Remover`.
8 changes: 7 additions & 1 deletion runtimes/js/encore.dev/storage/objects/bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getCurrentRequest } from "../../internal/reqtrack/mod";
import * as runtime from "../../internal/runtime/mod";
import { StringLiteral } from "../../internal/utils/constraints";
import { unwrapErr } from "./error";
import { BucketPerms, Uploader, Downloader, Attrser, Lister, Remover } from "./refs";

export interface BucketConfig {
/**
Expand All @@ -14,14 +15,15 @@ export interface BucketConfig {
/**
* Defines a new Object Storage bucket infrastructure resource.
*/
export class Bucket {
export class Bucket extends BucketPerms implements Uploader, Downloader, Attrser, Lister, Remover {
impl: runtime.Bucket;

/**
* Creates a new bucket with the given name and configuration
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
constructor(name: string, cfg?: BucketConfig) {
super();
this.impl = runtime.RT.bucket(name);
}

Expand Down Expand Up @@ -100,6 +102,10 @@ export class Bucket {
unwrapErr(err);
}
}

ref<P extends BucketPerms>(): P {
return this as unknown as P
}
}

export interface ListOptions {
Expand Down
1 change: 1 addition & 0 deletions runtimes/js/encore.dev/storage/objects/mod.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { Bucket } from "./bucket";
export type { BucketConfig, ObjectAttrs, UploadOptions } from "./bucket";
export { ObjectsError, ObjectNotFound, PreconditionFailed } from "./error";
export type { BucketPerms, Uploader, Downloader, Attrser, Lister, ReadWriter } from "./refs";
33 changes: 33 additions & 0 deletions runtimes/js/encore.dev/storage/objects/refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { AttrsOptions, DeleteOptions, DownloadOptions, ExistsOptions, ListEntry, ListOptions, ObjectAttrs, UploadOptions } from "./bucket";

export abstract class BucketPerms {
private bucketPerms(): void { };
}

export abstract class Uploader extends BucketPerms {
abstract upload(name: string, data: Buffer, options?: UploadOptions): Promise<ObjectAttrs>;
}

export abstract class Downloader extends BucketPerms {
abstract download(name: string, options?: DownloadOptions): Promise<Buffer>;
}

export abstract class Attrser extends BucketPerms {
abstract attrs(name: string, options?: AttrsOptions): Promise<ObjectAttrs>;
abstract exists(name: string, options?: ExistsOptions): Promise<boolean>;
}

export abstract class Lister extends BucketPerms {
abstract list(options: ListOptions): AsyncGenerator<ListEntry>;
}

export abstract class Remover extends BucketPerms {
abstract remove(name: string, options?: DeleteOptions): Promise<void>;
}

export type ReadWriter =
& Uploader
& Downloader
& Attrser
& Lister
& Remover;
6 changes: 3 additions & 3 deletions tsparser/src/legacymeta/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ impl<'a> MetaBuilder<'a> {
};

use objects::Operation;
let op = match access.op {
let ops = access.ops.iter().map(|op| match op {
Operation::DeleteObject => v1::bucket_usage::Operation::DeleteObject,
Operation::ListObjects => v1::bucket_usage::Operation::ListObjects,
Operation::ReadObjectContents => {
Expand All @@ -401,7 +401,7 @@ impl<'a> MetaBuilder<'a> {
Operation::GetObjectMetadata => {
v1::bucket_usage::Operation::GetObjectMetadata
}
};
} as i32);

let idx = svc_index.get(&svc.name).unwrap();
bucket_perms
Expand All @@ -411,7 +411,7 @@ impl<'a> MetaBuilder<'a> {
operations: vec![],
})
.operations
.push(op as i32);
.extend(ops);
}

Usage::CallEndpoint(call) => {
Expand Down
5 changes: 3 additions & 2 deletions tsparser/src/parser/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,13 @@ impl<'a> Parser<'a> {
resources.extend(additional_resources);
binds.extend(additional_binds);

let resolver = UsageResolver::new(&self.pc.loader, &resources, &binds);
let resolver =
UsageResolver::new(&self.pc.loader, &self.pc.type_checker, &resources, &binds);
let mut usages = Vec::new();

for module in self.pc.loader.modules() {
let exprs = resolver.scan_usage_exprs(&module);
let u = resolver.resolve_usage(&exprs)?;
let u = resolver.resolve_usage(&module, &exprs)?;
usages.extend(u);
}

Expand Down
122 changes: 119 additions & 3 deletions tsparser/src/parser/resources/infra/objects.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::ops::Deref;

use anyhow::Result;
use litparser::LitParser;
use litparser_derive::LitParser;
Expand All @@ -12,7 +14,8 @@ use crate::parser::resources::parseutil::{iter_references, TrackedNames};
use crate::parser::resources::parseutil::{NamedClassResourceOptionalConfig, NamedStaticMethod};
use crate::parser::resources::Resource;
use crate::parser::resources::ResourcePath;
use crate::parser::usageparser::{ResolveUsageData, Usage, UsageExprKind};
use crate::parser::types::{Generic, Type};
use crate::parser::usageparser::{MethodCall, ResolveUsageData, Usage, UsageExprKind};
use crate::parser::Range;
use crate::span_err::ErrReporter;

Expand Down Expand Up @@ -94,6 +97,24 @@ pub const OBJECTS_PARSER: ResourceParser = ResourceParser {
pub fn resolve_bucket_usage(data: &ResolveUsageData, bucket: Lrc<Bucket>) -> Result<Option<Usage>> {
Ok(match &data.expr.kind {
UsageExprKind::MethodCall(call) => {
if call.method.as_ref() == "ref" {
let Some(type_args) = call.call.type_args.as_deref() else {
call.call
.span
.err("expected a type argument in call to Bucket.ref");
return Ok(None);
};

let Some(type_arg) = type_args.params.first() else {
call.call
.span
.err("expected a type argument in call to Bucket.ref");
return Ok(None);
};

return parse_bucket_ref(data, bucket, call, type_arg);
}

let op = match call.method.as_ref() {
"list" => Operation::ListObjects,
"exists" | "attrs" => Operation::GetObjectMetadata,
Expand All @@ -109,7 +130,7 @@ pub fn resolve_bucket_usage(data: &ResolveUsageData, bucket: Lrc<Bucket>) -> Res
Some(Usage::Bucket(BucketUsage {
range: data.expr.range,
bucket,
op,
ops: vec![op],
}))
}

Expand All @@ -123,11 +144,106 @@ pub fn resolve_bucket_usage(data: &ResolveUsageData, bucket: Lrc<Bucket>) -> Res
})
}

fn parse_bucket_ref(
data: &ResolveUsageData,
bucket: Lrc<Bucket>,
_call: &MethodCall,
type_arg: &ast::TsType,
) -> Result<Option<Usage>> {
fn process_type(
data: &ResolveUsageData,
sp: &swc_common::Span,
t: &Type,
depth: usize,
) -> Option<Vec<Operation>> {
if depth > 10 {
// Prevent infinite recursion.
return None;
}

match t {
Type::Named(named) => {
let ops = match named.obj.name.as_deref() {
Some("Lister") => vec![Operation::ListObjects],
Some("Attrser") => vec![Operation::GetObjectMetadata],
Some("Uploader") => vec![Operation::WriteObject],
Some("Downloader") => vec![Operation::ReadObjectContents],
Some("Remover") => vec![Operation::DeleteObject],
_ => {
let underlying = data.type_checker.resolve_obj_type(&named.obj);
return process_type(data, sp, &underlying, depth + 1);
}
};

Some(ops)
}

Type::Class(cls) => {
let ops = cls
.methods
.iter()
.filter_map(|method| {
let op = match method.as_str() {
"list" => Operation::ListObjects,
"exists" | "attrs" => Operation::GetObjectMetadata,
"upload" => Operation::WriteObject,
"download" => Operation::ReadObjectContents,
"remove" => Operation::DeleteObject,
_ => {
// Ignore other methods.
return None;
}
};

Some(op)
})
.collect();
Some(ops)
}

Type::Generic(Generic::Intersection(int)) => {
let mut result = Vec::new();
for t in &[&int.x, &int.y] {
if let Some(ops) = process_type(data, sp, t, depth + 1) {
result.extend(ops);
}
}

if result.is_empty() {
None
} else {
Some(result)
}
}

_ => {
sp.err(&format!("unsupported bucket permission type {:#?}", t));
None
}
}
}

let typ = data
.type_checker
.resolve_type(data.module.clone(), type_arg);

if let Some(ops) = process_type(data, &typ.span(), typ.deref(), 0) {
Ok(Some(Usage::Bucket(BucketUsage {
range: data.expr.range,
bucket,
ops,
})))
} else {
typ.err("no bucket permissions found in type argument");
Ok(None)
}
}

#[derive(Debug)]
pub struct BucketUsage {
pub range: Range,
pub bucket: Lrc<Bucket>,
pub op: Operation,
pub ops: Vec<Operation>,
}

#[derive(Debug)]
Expand Down
4 changes: 2 additions & 2 deletions tsparser/src/parser/types/object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ pub enum TypeNameDecl {
#[derive(Debug)]
pub struct Class {
#[allow(dead_code)]
spec: Box<ast::Class>,
pub spec: Box<ast::Class>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -679,7 +679,7 @@ impl ResolveState {
})
}

pub(super) fn lookup_module(&self, id: ModuleId) -> Option<Rc<Module>> {
pub fn lookup_module(&self, id: ModuleId) -> Option<Rc<Module>> {
self.modules.borrow().get(&id).cloned()
}

Expand Down
9 changes: 5 additions & 4 deletions tsparser/src/parser/types/typ.rs
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ impl InterfaceField {

#[derive(Debug, Clone, Hash, Serialize)]
pub struct ClassType {
pub methods: Vec<String>,
// TODO: include class fields here
}

Expand Down Expand Up @@ -826,10 +827,10 @@ pub fn intersect<'a: 'b, 'b>(
}

(Type::Class(_), Type::Class(_)) => {
HANDLER.with(|handler| {
handler.err("intersection of class types is not yet supported");
});
Cow::Owned(Type::Basic(Basic::Never))
Cow::Owned(Type::Generic(Generic::Intersection(Intersection {
x: Box::new(a.into_owned()),
y: Box::new(b.into_owned()),
})))
}

(Type::Named(x), _) => {
Expand Down
15 changes: 14 additions & 1 deletion tsparser/src/parser/types/type_resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1381,7 +1381,20 @@ impl<'a> Ctx<'a> {
Type::Basic(Basic::Never)
}

ObjectKind::Class(_o) => Type::Class(ClassType {}),
ObjectKind::Class(o) => {
let methods = o
.spec
.body
.iter()
.filter_map(|mem| match mem {
ast::ClassMember::Method(m) => {
m.key.as_ident().map(|id| id.sym.to_string())
}
_ => None,
})
.collect();
Type::Class(ClassType { methods })
}

ObjectKind::Module(_o) => Type::Basic(Basic::Never),
ObjectKind::Namespace(_o) => {
Expand Down
Loading

0 comments on commit 268e27e

Please sign in to comment.