Packages

LiveView-style runtime for Gleam.

Current section

Files

Jump to
lightspeed src lightspeed upload.gleam
Raw

src/lightspeed/upload.gleam

//// LiveView-style upload compatibility model.
import gleam/int
import gleam/list
import gleam/string
/// Upload transport mode.
pub type UploadMode {
DirectServer
ExternalClient(provider: String)
}
/// Upload entry lifecycle status.
pub type EntryStatus {
Selected
ExternalPrepared(upload_url: String)
Uploading(progress: Int)
Completed(location: String)
Cancelled(reason: String)
Failed(reason: String)
}
/// One upload entry.
pub type UploadEntry {
UploadEntry(
ref: String,
client_name: String,
client_size: Int,
client_type: String,
status: EntryStatus,
)
}
/// Upload-level validation and transition errors.
pub type UploadError {
TooManyFiles(max_entries: Int)
TooLarge(ref: String, size_bytes: Int, max_bytes: Int)
NotAccepted(ref: String, accept: List(String))
DuplicateRef(ref: String)
UnknownRef(ref: String)
InvalidProgress(ref: String, progress: Int)
InvalidTransition(ref: String, status: String, action: String)
}
/// Upload runtime state.
pub opaque type Upload {
Upload(
name: String,
mode: UploadMode,
accept: List(String),
max_entries: Int,
max_file_size: Int,
entries_rev: List(UploadEntry),
errors_rev: List(UploadError),
)
}
/// Build one upload configuration.
pub fn allow(
name: String,
mode: UploadMode,
accept: List(String),
max_entries: Int,
max_file_size: Int,
) -> Upload {
Upload(
name: name,
mode: mode,
accept: accept,
max_entries: max_entries,
max_file_size: max_file_size,
entries_rev: [],
errors_rev: [],
)
}
/// Upload name.
pub fn name(upload: Upload) -> String {
upload.name
}
/// Upload mode.
pub fn mode(upload: Upload) -> UploadMode {
upload.mode
}
/// Entries in selection order.
pub fn entries(upload: Upload) -> List(UploadEntry) {
list.reverse(upload.entries_rev)
}
/// Errors in occurrence order.
pub fn errors(upload: Upload) -> List(UploadError) {
list.reverse(upload.errors_rev)
}
/// Add one selected file entry with validation.
pub fn add_entry(
upload: Upload,
ref: String,
client_name: String,
client_size: Int,
client_type: String,
) -> #(Upload, Result(UploadEntry, UploadError)) {
case list.length(upload.entries_rev) >= upload.max_entries {
True -> reject(upload, TooManyFiles(max_entries: upload.max_entries))
False ->
case has_ref(upload.entries_rev, ref) {
True -> reject(upload, DuplicateRef(ref: ref))
False ->
case client_size > upload.max_file_size {
True ->
reject(
upload,
TooLarge(
ref: ref,
size_bytes: client_size,
max_bytes: upload.max_file_size,
),
)
False ->
case accepted(upload.accept, client_name, client_type) {
False ->
reject(upload, NotAccepted(ref: ref, accept: upload.accept))
True -> {
let entry =
UploadEntry(
ref: ref,
client_name: client_name,
client_size: client_size,
client_type: client_type,
status: Selected,
)
#(
Upload(..upload, entries_rev: [entry, ..upload.entries_rev]),
Ok(entry),
)
}
}
}
}
}
}
/// Prepare one external upload entry with a preflight URL.
pub fn prepare_external(
upload: Upload,
ref: String,
upload_url: String,
) -> #(Upload, Result(UploadEntry, UploadError)) {
case upload.mode {
DirectServer ->
reject(
upload,
InvalidTransition(
ref: ref,
status: "direct_mode",
action: "prepare_external",
),
)
ExternalClient(_) ->
transition(upload, ref, "prepare_external", fn(entry) {
case entry.status {
Selected ->
Ok(
UploadEntry(
..entry,
status: ExternalPrepared(upload_url: upload_url),
),
)
_ ->
Error(InvalidTransition(
ref: ref,
status: entry_status_label(entry.status),
action: "prepare_external",
))
}
})
}
}
/// Start uploading one entry.
pub fn start_upload(
upload: Upload,
ref: String,
) -> #(Upload, Result(UploadEntry, UploadError)) {
transition(upload, ref, "start_upload", fn(entry) {
case upload.mode, entry.status {
DirectServer, Selected ->
Ok(UploadEntry(..entry, status: Uploading(progress: 0)))
ExternalClient(_), ExternalPrepared(_) ->
Ok(UploadEntry(..entry, status: Uploading(progress: 0)))
_, _ ->
Error(InvalidTransition(
ref: ref,
status: entry_status_label(entry.status),
action: "start_upload",
))
}
})
}
/// Update upload progress.
pub fn progress(
upload: Upload,
ref: String,
progress: Int,
) -> #(Upload, Result(UploadEntry, UploadError)) {
case progress < 0 || progress > 100 {
True -> reject(upload, InvalidProgress(ref: ref, progress: progress))
False ->
transition(upload, ref, "progress", fn(entry) {
case entry.status {
ExternalPrepared(_) ->
Ok(UploadEntry(..entry, status: Uploading(progress: progress)))
Uploading(_) ->
Ok(UploadEntry(..entry, status: Uploading(progress: progress)))
_ ->
Error(InvalidTransition(
ref: ref,
status: entry_status_label(entry.status),
action: "progress",
))
}
})
}
}
/// Complete one upload entry.
pub fn complete(
upload: Upload,
ref: String,
location: String,
) -> #(Upload, Result(UploadEntry, UploadError)) {
transition(upload, ref, "complete", fn(entry) {
case entry.status {
Uploading(_) ->
Ok(UploadEntry(..entry, status: Completed(location: location)))
_ ->
Error(InvalidTransition(
ref: ref,
status: entry_status_label(entry.status),
action: "complete",
))
}
})
}
/// Mark one upload entry as failed.
pub fn fail(
upload: Upload,
ref: String,
reason: String,
) -> #(Upload, Result(UploadEntry, UploadError)) {
transition(upload, ref, "fail", fn(entry) {
case entry.status {
Uploading(_) -> Ok(UploadEntry(..entry, status: Failed(reason: reason)))
_ ->
Error(InvalidTransition(
ref: ref,
status: entry_status_label(entry.status),
action: "fail",
))
}
})
}
/// Cancel one upload entry.
pub fn cancel(
upload: Upload,
ref: String,
reason: String,
) -> #(Upload, Result(UploadEntry, UploadError)) {
transition(upload, ref, "cancel", fn(entry) {
case entry.status {
Selected -> Ok(UploadEntry(..entry, status: Cancelled(reason: reason)))
ExternalPrepared(_) ->
Ok(UploadEntry(..entry, status: Cancelled(reason: reason)))
Uploading(_) ->
Ok(UploadEntry(..entry, status: Cancelled(reason: reason)))
_ ->
Error(InvalidTransition(
ref: ref,
status: entry_status_label(entry.status),
action: "cancel",
))
}
})
}
/// Stable status label for assertions and logs.
pub fn entry_status_label(status: EntryStatus) -> String {
case status {
Selected -> "selected"
ExternalPrepared(_) -> "external_prepared"
Uploading(_) -> "uploading"
Completed(_) -> "completed"
Cancelled(_) -> "cancelled"
Failed(_) -> "failed"
}
}
/// Stable error label for assertions and logs.
pub fn error_to_string(error: UploadError) -> String {
case error {
TooManyFiles(max_entries) -> "too_many_files:" <> int.to_string(max_entries)
TooLarge(ref, size_bytes, max_bytes) ->
"too_large:"
<> ref
<> ":"
<> int.to_string(size_bytes)
<> ":"
<> int.to_string(max_bytes)
NotAccepted(ref, _) -> "not_accepted:" <> ref
DuplicateRef(ref) -> "duplicate_ref:" <> ref
UnknownRef(ref) -> "unknown_ref:" <> ref
InvalidProgress(ref, progress) ->
"invalid_progress:" <> ref <> ":" <> int.to_string(progress)
InvalidTransition(ref, status, action) ->
"invalid_transition:" <> ref <> ":" <> status <> ":" <> action
}
}
fn transition(
upload: Upload,
ref: String,
_action: String,
with: fn(UploadEntry) -> Result(UploadEntry, UploadError),
) -> #(Upload, Result(UploadEntry, UploadError)) {
case update_entry(upload.entries_rev, ref, with, []) {
Error(error) -> reject(upload, error)
Ok(#(entries_rev, entry)) -> #(
Upload(..upload, entries_rev: entries_rev),
Ok(entry),
)
}
}
fn update_entry(
entries_rev: List(UploadEntry),
ref: String,
with: fn(UploadEntry) -> Result(UploadEntry, UploadError),
seen_rev: List(UploadEntry),
) -> Result(#(List(UploadEntry), UploadEntry), UploadError) {
case entries_rev {
[] -> Error(UnknownRef(ref: ref))
[entry, ..rest] ->
case entry.ref == ref {
True ->
case with(entry) {
Error(error) -> Error(error)
Ok(updated) ->
Ok(#(reverse_into(seen_rev, [updated, ..rest]), updated))
}
False -> update_entry(rest, ref, with, [entry, ..seen_rev])
}
}
}
fn reject(
upload: Upload,
error: UploadError,
) -> #(Upload, Result(UploadEntry, UploadError)) {
#(Upload(..upload, errors_rev: [error, ..upload.errors_rev]), Error(error))
}
fn has_ref(entries_rev: List(UploadEntry), ref: String) -> Bool {
case entries_rev {
[] -> False
[entry, ..rest] ->
case entry.ref == ref {
True -> True
False -> has_ref(rest, ref)
}
}
}
fn accepted(
accept: List(String),
client_name: String,
client_type: String,
) -> Bool {
case accept {
[] -> True
_ ->
accepted_any(
accept,
string.lowercase(client_name),
string.lowercase(client_type),
)
}
}
fn accepted_any(
patterns: List(String),
client_name: String,
client_type: String,
) -> Bool {
case patterns {
[] -> False
[pattern, ..rest] ->
case
accepted_pattern(string.lowercase(pattern), client_name, client_type)
{
True -> True
False -> accepted_any(rest, client_name, client_type)
}
}
}
fn accepted_pattern(
pattern: String,
client_name: String,
client_type: String,
) -> Bool {
case pattern {
"*/*" -> True
_ ->
case string.starts_with(pattern, ".") {
True -> string.ends_with(client_name, pattern)
False ->
case string.ends_with(pattern, "/*") {
True ->
string.starts_with(client_type, string.replace(pattern, "*", ""))
False -> client_type == pattern
}
}
}
}
fn reverse_into(left: List(a), right: List(a)) -> List(a) {
case left {
[] -> right
[entry, ..rest] -> reverse_into(rest, [entry, ..right])
}
}