From 59599fe6f91c3c8b692f2167654cc7b57c6c98ff Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Mon, 27 Sep 2021 15:04:33 +1000 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20switched?= =?UTF-8?q?=20to=20`thiserror`=20and=20`anyhow`=20in=20the=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Error reporting is now much nicer. --- examples/basic/.perseus/.gitignore | 1 + packages/perseus-cli/Cargo.toml | 3 +- packages/perseus-cli/src/bin/main.rs | 21 ++- packages/perseus-cli/src/build.rs | 25 +-- packages/perseus-cli/src/cmd.rs | 10 +- packages/perseus-cli/src/eject.rs | 24 +-- packages/perseus-cli/src/errors.rs | 267 +++++++++++++++------------ packages/perseus-cli/src/export.rs | 53 +++--- packages/perseus-cli/src/lib.rs | 25 ++- packages/perseus-cli/src/prepare.rs | 72 ++++---- packages/perseus-cli/src/serve.rs | 107 ++++++----- 11 files changed, 335 insertions(+), 273 deletions(-) diff --git a/examples/basic/.perseus/.gitignore b/examples/basic/.perseus/.gitignore index 849ddff3b7..5076b767e0 100644 --- a/examples/basic/.perseus/.gitignore +++ b/examples/basic/.perseus/.gitignore @@ -1 +1,2 @@ dist/ +target/ diff --git a/packages/perseus-cli/Cargo.toml b/packages/perseus-cli/Cargo.toml index a01c4b2f26..199898e6f3 100644 --- a/packages/perseus-cli/Cargo.toml +++ b/packages/perseus-cli/Cargo.toml @@ -20,7 +20,8 @@ include = [ [dependencies] include_dir = "0.6" -error-chain = "0.12" +thiserror = "1" +anyhow = "1" cargo_toml = "0.9" indicatif = "0.17.0-beta.1" # Not stable, but otherwise error handling is just about impossible console = "0.14" diff --git a/packages/perseus-cli/src/bin/main.rs b/packages/perseus-cli/src/bin/main.rs index 71a4013cf9..556f8d5c05 100644 --- a/packages/perseus-cli/src/bin/main.rs +++ b/packages/perseus-cli/src/bin/main.rs @@ -1,7 +1,7 @@ use perseus_cli::errors::*; use perseus_cli::{ build, check_env, delete_artifacts, delete_bad_dir, eject, export, has_ejected, help, prepare, - serve, PERSEUS_VERSION, + report_err, serve, PERSEUS_VERSION, }; use std::env; use std::io::Write; @@ -26,8 +26,7 @@ fn real_main() -> i32 { let dir = match dir { Ok(dir) => dir, Err(err) => { - let err = ErrorKind::CurrentDirUnavailable(err.to_string()); - eprintln!("{}", err); + report_err!(PrepError::CurrentDirUnavailable { source: err }); return 1; } }; @@ -37,11 +36,12 @@ fn real_main() -> i32 { Ok(exit_code) => exit_code, // If something failed, we print the error to `stderr` and return a failure exit code Err(err) => { - eprintln!("{}", err); + let should_cause_deletion = err_should_cause_deletion(&err); + report_err!(err); // Check if the error needs us to delete a partially-formed '.perseus/' directory - if err_should_cause_deletion(&err) { + if should_cause_deletion { if let Err(err) = delete_bad_dir(dir) { - eprintln!("{}", err); + report_err!(err); } } 1 @@ -53,7 +53,7 @@ fn real_main() -> i32 { // This returns the exit code of the executed command, which we should return from the process itself // This prints warnings using the `writeln!` macro, which allows the parsing of `stdout` in production or a vector in testing // If at any point a warning can't be printed, the program will panic -fn core(dir: PathBuf) -> Result { +fn core(dir: PathBuf) -> Result { // Get `stdout` so we can write warnings appropriately let stdout = &mut std::io::stdout(); // Get the arguments to this program, removing the first one (something like `perseus`) @@ -123,15 +123,16 @@ fn core(dir: PathBuf) -> Result { eject(dir)?; Ok(0) } else if prog_args[0] == "clean" { - if prog_args[1] == "--dist" { + if prog_args.get(1) == Some(&"--dist".to_string()) { // The user only wants to remove distribution artifacts // We don't delete `render_conf.json` because it's literally impossible for that to be the source of a problem right now delete_artifacts(dir.clone(), "static")?; delete_artifacts(dir, "pkg")?; } else { // This command deletes the `.perseus/` directory completely, which musn't happen if the user has ejected - if has_ejected(dir.clone()) && prog_args[1] != "--force" { - bail!(ErrorKind::CleanAfterEjection) + if has_ejected(dir.clone()) && prog_args.get(1) != Some(&"--force".to_string()) + { + return Err(EjectionError::CleanAfterEject.into()); } // Just delete the '.perseus/' directory directly, as we'd do in a corruption delete_bad_dir(dir)?; diff --git a/packages/perseus-cli/src/build.rs b/packages/perseus-cli/src/build.rs index 8ca7a23739..4a58e95636 100644 --- a/packages/perseus-cli/src/build.rs +++ b/packages/perseus-cli/src/build.rs @@ -16,23 +16,23 @@ macro_rules! handle_exit_code { ($code:expr) => { let (_, _, code) = $code; if code != 0 { - return $crate::errors::Result::Ok(code); + return ::std::result::Result::Ok(code); } }; } /// Finalizes the build by renaming some directories. -pub fn finalize(target: &Path) -> Result<()> { +pub fn finalize(target: &Path) -> Result<(), ExecutionError> { // Move the `pkg/` directory into `dist/pkg/` let pkg_dir = target.join("dist/pkg"); if pkg_dir.exists() { if let Err(err) = fs::remove_dir_all(&pkg_dir) { - bail!(ErrorKind::MovePkgDirFailed(err.to_string())); + return Err(ExecutionError::MovePkgDirFailed { source: err }); } } // The `fs::rename()` function will fail on Windows if the destination already exists, so this should work (we've just deleted it as per https://github.com/rust-lang/rust/issues/31301#issuecomment-177117325) if let Err(err) = fs::rename(target.join("pkg"), target.join("dist/pkg")) { - bail!(ErrorKind::MovePkgDirFailed(err.to_string())); + return Err(ExecutionError::MovePkgDirFailed { source: err }); } Ok(()) @@ -46,10 +46,13 @@ pub fn build_internal( dir: PathBuf, spinners: &MultiProgress, num_steps: u8, -) -> Result<( - ThreadHandle Result, Result>, - ThreadHandle Result, Result>, -)> { +) -> Result< + ( + ThreadHandle Result, Result>, + ThreadHandle Result, Result>, + ), + ExecutionError, +> { let target = dir.join(".perseus"); // Static generation message @@ -104,19 +107,19 @@ pub fn build_internal( } /// Builds the subcrates to get a directory that we can serve. Returns an exit code. -pub fn build(dir: PathBuf, _prog_args: &[String]) -> Result { +pub fn build(dir: PathBuf, _prog_args: &[String]) -> Result { let spinners = MultiProgress::new(); let (sg_thread, wb_thread) = build_internal(dir.clone(), &spinners, 2)?; let sg_res = sg_thread .join() - .map_err(|_| ErrorKind::ThreadWaitFailed)??; + .map_err(|_| ExecutionError::ThreadWaitFailed)??; if sg_res != 0 { return Ok(sg_res); } let wb_res = wb_thread .join() - .map_err(|_| ErrorKind::ThreadWaitFailed)??; + .map_err(|_| ExecutionError::ThreadWaitFailed)??; if wb_res != 0 { return Ok(wb_res); } diff --git a/packages/perseus-cli/src/cmd.rs b/packages/perseus-cli/src/cmd.rs index 6de6ac7133..177e3aa95d 100644 --- a/packages/perseus-cli/src/cmd.rs +++ b/packages/perseus-cli/src/cmd.rs @@ -11,7 +11,11 @@ pub static FAILURE: Emoji<'_, '_> = Emoji("❌", "failed!"); /// Runs the given command conveniently, returning the exit code. Notably, this parses the given command by separating it on spaces. /// Returns the command's output and the exit code. -pub fn run_cmd(cmd: String, dir: &Path, pre_dump: impl Fn()) -> Result<(String, String, i32)> { +pub fn run_cmd( + cmd: String, + dir: &Path, + pre_dump: impl Fn(), +) -> Result<(String, String, i32), ExecutionError> { // We run the command in a shell so that NPM/Yarn binaries can be recognized (see #5) #[cfg(unix)] let shell_exec = "sh"; @@ -27,7 +31,7 @@ pub fn run_cmd(cmd: String, dir: &Path, pre_dump: impl Fn()) -> Result<(String, .args([shell_param, &cmd]) .current_dir(dir) .output() - .map_err(|err| ErrorKind::CmdExecFailed(cmd.clone(), err.to_string()))?; + .map_err(|err| ExecutionError::CmdExecFailed { cmd, source: err })?; let exit_code = match output.status.code() { Some(exit_code) => exit_code, // If we have an exit code, use it @@ -73,7 +77,7 @@ pub fn run_stage( target: &Path, spinner: &ProgressBar, message: &str, -) -> Result<(String, String, i32)> { +) -> Result<(String, String, i32), ExecutionError> { let mut last_output = (String::new(), String::new()); // Run the commands for cmd in cmds { diff --git a/packages/perseus-cli/src/eject.rs b/packages/perseus-cli/src/eject.rs index 7fb8ef1622..7741555980 100644 --- a/packages/perseus-cli/src/eject.rs +++ b/packages/perseus-cli/src/eject.rs @@ -4,42 +4,44 @@ use std::path::PathBuf; /// Ejects the user from the Perseus CLi harness by exposing the internal subcrates to them. All this does is remove `.perseus/` from /// the user's `.gitignore` and add a file `.ejected` to `.perseus/`. -pub fn eject(dir: PathBuf) -> Result<()> { +pub fn eject(dir: PathBuf) -> Result<(), EjectionError> { // Create a file declaring ejection so `clean` throws errors (we don't want the user to accidentally delete everything) let ejected = dir.join(".perseus/.ejected"); fs::write( &ejected, "This file signals to Perseus that you've ejected. Do NOT delete it!", ) - .map_err(|err| ErrorKind::GitignoreEjectUpdateFailed(err.to_string()))?; + .map_err(|err| EjectionError::GitignoreUpdateFailed { source: err })?; // Now remove `.perseus/` from the user's `.gitignore` let gitignore = dir.join(".gitignore"); if gitignore.exists() { let content = fs::read_to_string(&gitignore) - .map_err(|err| ErrorKind::GitignoreEjectUpdateFailed(err.to_string()))?; + .map_err(|err| EjectionError::GitignoreUpdateFailed { source: err })?; let mut new_content_vec = Vec::new(); // Remove the line pertaining to Perseus // We only target the one that's exactly the same as what's automatically injected, anything else can be done manually + let mut have_changed = false; for line in content.lines() { if line != ".perseus/" { new_content_vec.push(line); + } else { + have_changed = true; } } let new_content = new_content_vec.join("\n"); // Make sure we've actually changed something - if content == new_content { - bail!(ErrorKind::GitignoreEjectUpdateFailed( - "line `.perseus/` to remove not found".to_string() - )) + if !have_changed { + return Err(EjectionError::GitignoreLineNotPresent); } fs::write(&gitignore, new_content) - .map_err(|err| ErrorKind::GitignoreEjectUpdateFailed(err.to_string()))?; + .map_err(|err| EjectionError::GitignoreUpdateFailed { source: err })?; Ok(()) } else { - bail!(ErrorKind::GitignoreEjectUpdateFailed( - "file not found".to_string() - )) + // The file wasn't found + Err(EjectionError::GitignoreUpdateFailed { + source: std::io::Error::from(std::io::ErrorKind::NotFound), + }) } } diff --git a/packages/perseus-cli/src/errors.rs b/packages/perseus-cli/src/errors.rs index 53e985a7c5..dd2e1283bd 100644 --- a/packages/perseus-cli/src/errors.rs +++ b/packages/perseus-cli/src/errors.rs @@ -1,124 +1,165 @@ #![allow(missing_docs)] -pub use error_chain::bail; -use error_chain::error_chain; +use thiserror::Error; -// The `error_chain` setup for the whole crate -error_chain! { - // The custom errors for this crate (very broad) - errors { - /// For when executing a prerequisite command fails. - PrereqFailed(cmd: String, env_var: String, err: String) { - description("prerequisite command execution failed") - display("You seem to be missing the prerequisite '{}', which is required for the Perseus CLI to work. If you've installed it at another path, please provide the executable through the '{}' variable. Error was: '{}'.", cmd, env_var, err) - } - /// For when the user's curreent directory couldn't be found. - CurrentDirUnavailable(err: String) { - description("couldn't get current directory") - display("Couldn't get your current directory. This is probably an issue with your system configuration. Error was: '{}'.", err) - } - /// For when extracting the subcrates failed. - // The `PathBuf` will be converted to a string, and unwrapping is bad in that context - ExtractionFailed(target_dir: Option, err: String) { - description("subcrate extraction failed") - display("Couldn't extract internal subcrates to '{:?}'. You may not have the permissions necessary to write to this location, or the directory disappeared out from under the CLI. The '.perseus/' directory has been automatically deleted for safety. Error was: '{}'.", target_dir, err) - } - /// For when updating the user's .gitignore fails - GitignoreUpdateFailed(err: String) { - description("updating gitignore failed") - display("Couldn't update your .gitignore file to ignore the Perseus subcrates. The '.perseus/' directory has been automatically deleted (necessary further steps not executed). Error was: '{}'.", err) - } - /// For when updating relative paths and package names in the manifest failed. - ManifestUpdateFailed(target: Option, err: String) { - description("updating manifests failed") - display("Couldn't update internal manifest file at '{:?}'. If the error persists, make sure you have file write permissions. The '.perseus/' directory has been automatically deleted. Error was: '{}'.", target, err) - } - /// For when we can't get the user's `Cargo.toml` file. - GetUserManifestFailed(err: String) { - description("reading user manifest failed") - display("Couldn't read your crate's manifest (Cargo.toml) file. Please make sure this file exists, is valid, and that you're running Perseus in the right directory.The '.perseus/' directory has been automatically deleted. Error was: '{}'.", err) - } - /// For when a partially-formed '.perseus/' directory couldn't be removed, but did exist. - RemoveBadDirFailed(target: Option, err: String) { - description("removing corrupted '.perseus/' directory failed") - display("Couldn't remove '.perseus/' directory at '{:?}'. Please remove the '.perseus/' directory manually (particularly if you didn't intentionally run the 'clean' command, that means the directory has been corrupted). Error was: '{}'.", target, err) - } - /// For when executing a system command after preparation failed. This shouldn't cause a directory deletion. - CmdExecFailed(cmd: String, err: String) { - description("command exeuction failed") - display("Couldn't execute command '{}'. Error was: '{}'.", cmd, err) - } - /// For when watching failes for changes failed. - WatcherFailed(path: String, err: String) { - description("watching files failed") - display("Couldn't watch '{}' for changes. Error was: '{}'.", path, err) - } - /// For when the next line of the stdout of a command is `None` when it shouldn't have been. - NextStdoutLineNone { - description("next stdout line was None, expected Some(_)") - display("Executing a command failed because it seemed to stop reporting prmeaturely. If this error persists, you should file a bug report (particularly if you've just upgraded Rust).") - } - /// For when getting the path to the built executable for the server from the JSON build output failed. - GetServerExecutableFailed(err: String) { - description("getting server executable path failed") - display("Couldn't get the path to the server executable from `cargo build`. If this problem persists, please report it as a bug (especially if you just updated cargo). Error was: '{}'.", err) - } - /// For when getting the path to the built executable for the server from the JSON build output failed. - PortNotNumber(err: String) { - description("port in PORT environment variable couldn't be parsed as number") - display("Couldn't parse 'PORT' environment variable as a number, please check that you've provided the correct value. Error was: '{}'.", err) - } - /// For when build artifacts either couldn't be removed or the directory couldn't be recreated. - RemoveArtifactsFailed(target: Option, err: String) { - description("reconstituting build artifacts failed") - display("Couldn't remove and replace '.perseus/dist/static/' directory at '{:?}'. Please try again or run 'perseus clean' if the error persists. Error was: '{}'.", target, err) - } - /// For when moving the `pkg/` directory to `dist/pkg/` fails. - MovePkgDirFailed(err: String) { - description("couldn't move `pkg/` to `dist/pkg/`") - display("Couldn't move `.perseus/pkg/` to `.perseus/dist/pkg`. Error was: '{}'.", err) - } - /// For when an error occurs while trying to wait for a thread. - ThreadWaitFailed { - description("error occurred while trying to wait for thread") - display("Waiting on thread failed.") - } - /// For when updating the user's gitignore for ejection fails. - GitignoreEjectUpdateFailed(err: String) { - description("couldn't remove perseus subcrates from gitignore for ejection") - display("Couldn't remove `.perseus/` (Perseus subcrates) from your `.gitignore`. Please remove them manually, then ejection is complete (that's all this command does). Error was: '{}'.", err) - } - /// For when writing the file that signals that we've ejected fails. - EjectionWriteFailed(err: String) { - description("couldn't write ejection declaration file") - display("Couldn't create `.perseus/.ejected` file to signal that you've ejected. Please make sure you have permission to write to the `.perseus/` directory, and then try again. Error was: '{}'.", err) - } - /// For when the user tries to run `clean` after they've ejected. That command deletes the subcrates, which shouldn't happen - /// after an ejection (they'll likely have customized things). - CleanAfterEjection { - description("can't clean after ejection unless `--force` is provided") - display("The `clean` command removes the entire `.perseus/` directory, and you've already ejected, meaning that you can make modifications to that directory. If you proceed with this command, any modifications you've made to `.perseus/` will be PERMANENTLY lost! If you're sure you want to proceed, run `perseus clean --force`.") - } - /// For when copying an asset into the export package failed. - MoveExportAssetFailed(to: String, from: String, err: String) { - description("couldn't copy asset for exporting") - display("Couldn't copy asset from '{}' to '{}' for exporting. Error was: '{}'.", to, from, err) - } - /// For when creating the export directory structure failed. - ExportDirStructureFailed { - description("couldn't create necessary directory structure for exporting") - display("Couldn't create directory structure necessary for exporting. Please ensure that you have the necessary permissions to write in this folder.") - } - } +/// All errors that can be returned by the CLI. +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + PrepError(#[from] PrepError), + #[error(transparent)] + ExecutionError(#[from] ExecutionError), + #[error(transparent)] + EjectionError(#[from] EjectionError), + #[error(transparent)] + ExportError(#[from] ExportError), } +/// Errors that can occur while preparing. +#[derive(Error, Debug)] +pub enum PrepError { + #[error("prerequisite command execution failed for prerequisite '{cmd}' (set '{env_var}' to another location if you've installed it elsewhere)")] + PrereqNotPresent { + cmd: String, + env_var: String, + #[source] + source: std::io::Error, + }, + #[error("couldn't get current directory (have you just deleted it?)")] + CurrentDirUnavailable { + #[source] + source: std::io::Error, + }, + #[error("couldn't extract internal subcrates to '{target_dir:?}' (do you have the necessary permissions?)")] + ExtractionFailed { + target_dir: Option, + #[source] + source: std::io::Error, + }, + #[error("updating gitignore to ignore `.perseus/` failed (`.perseus/` has been automatically deleted)")] + GitignoreUpdateFailed { + #[source] + source: std::io::Error, + }, + #[error("couldn't update internal manifest file at '{target_dir:?}' (`.perseus/` has been automatically deleted)")] + ManifestUpdateFailed { + target_dir: Option, + #[source] + source: std::io::Error, + }, + #[error("couldn't get `Cargo.toml` for your project (have you run `cargo init` yet?)")] + GetUserManifestFailed { + #[source] + source: cargo_toml::Error, + }, + #[error( + "your project's `Cargo.toml` doesn't have a `[package]` section (package name is required)" + )] + MalformedUserManifest, + #[error("couldn't remove corrupted `.perseus/` directory as required by previous error (please delete `.perseus/` manually)")] + RemoveBadDirFailed { + #[source] + source: std::io::Error, + }, +} /// Checks if the given error should cause the CLI to delete the '.perseus/' folder so the user doesn't have something incomplete. /// When deleting the directory, it should only be deleted if it exists, if not don't worry. If it does and deletion fails, fail like hell. pub fn err_should_cause_deletion(err: &Error) -> bool { matches!( - err.kind(), - ErrorKind::ExtractionFailed(_, _) - | ErrorKind::GitignoreUpdateFailed(_) - | ErrorKind::ManifestUpdateFailed(_, _) + err, + Error::PrepError( + PrepError::ExtractionFailed { .. } + | PrepError::GitignoreUpdateFailed { .. } + | PrepError::ManifestUpdateFailed { .. } + ) ) } + +/// Errors that can occur while attempting to execute a Perseus app with `build`/`serve` (export errors are separate). +#[derive(Error, Debug)] +pub enum ExecutionError { + #[error("couldn't execute command '{cmd}' (this doesn't mean it threw an error, it means it couldn't be run at all)")] + CmdExecFailed { + cmd: String, + #[source] + source: std::io::Error, + }, + #[error("couldn't execute command because it prematurely stopped reporting (if this persists, please report it as a bug)")] + NextStdoutLineNone, + #[error("couldn't get path to server executable (if this persists, please report it as a bug, especially if you've just updated `cargo`)")] + GetServerExecutableFailed { + #[source] + source: serde_json::Error, + }, + #[error("expected second-last message from Cargo to contain server executable path, none existed (too few messages) (report this as a bug if it persists)")] + ServerExectutableMsgNotFound, + #[error("couldn't parse server executable path from Cargo (report this as a bug if it persists): {err}")] + ParseServerExecutableFailed { err: String }, + #[error("couldn't remove and replace internal build artifact directory '{target:?}' (run `perseus clean` if this persists)")] + RemoveArtifactsFailed { + target: Option, + #[source] + source: std::io::Error, + }, + #[error("couldn't move `.perseus/pkg/` to `.perseus/dist/pkg/` (run `perseus clean` if this persists)")] + MovePkgDirFailed { + #[source] + source: std::io::Error, + }, + #[error("failed to wait on thread (please report this as a bug if it persists)")] + ThreadWaitFailed, + #[error("value in `PORT` environment variable couldn't be parsed as a number")] + PortNotNumber { + #[source] + source: std::num::ParseIntError, + }, +} + +/// Errors that can occur while ejecting or as a result of doing so. +#[derive(Error, Debug)] +pub enum EjectionError { + #[error("couldn't remove perseus subcrates from gitignore for ejection")] + GitignoreUpdateFailed { + #[source] + source: std::io::Error, + }, + #[error("line `.perseus/` to remove not found in `.gitignore`")] + GitignoreLineNotPresent, + #[error("couldn't write ejection declaration file (`.perseus/.ejected`), please try again")] + DeclarationWriteFailed { + #[source] + source: std::io::Error, + }, + #[error("can't clean after ejection unless `--force` is provided (maybe you meant to use `--dist`?)")] + CleanAfterEject, +} + +/// Errors that can occur while running `perseus export`. +#[derive(Error, Debug)] +pub enum ExportError { + #[error("couldn't create directory structure necessary for exporting (do you have the necessary permissions?)")] + DirStructureCreationFailed { + #[source] + source: std::io::Error, + }, + #[error("couldn't copy asset from '{to}' to '{from}' for exporting")] + MoveAssetFailed { + to: String, + from: String, + #[source] + source: std::io::Error, + }, + // We need to execute in exports + #[error(transparent)] + ExecutionError(#[from] ExecutionError), +} + +/// Reports the given error to the user with `anyhow`. +#[macro_export] +macro_rules! report_err { + ($err:expr) => { + let err = ::anyhow::anyhow!($err); + // This will include a `Caused by` section + eprintln!("Error: {:?}", err); + }; +} diff --git a/packages/perseus-cli/src/export.rs b/packages/perseus-cli/src/export.rs index 7b84c78b48..b764fbff3d 100644 --- a/packages/perseus-cli/src/export.rs +++ b/packages/perseus-cli/src/export.rs @@ -16,7 +16,7 @@ macro_rules! handle_exit_code { ($code:expr) => { let (_, _, code) = $code; if code != 0 { - return $crate::errors::Result::Ok(code); + return ::std::result::Result::Ok(code); } }; } @@ -26,27 +26,27 @@ macro_rules! handle_exit_code { macro_rules! copy_file { ($from:expr, $to:expr, $target:expr) => { if let Err(err) = fs::copy($target.join($from), $target.join($to)) { - bail!(ErrorKind::MoveExportAssetFailed( - $from.to_string(), - $to.to_string(), - err.to_string() - )); + return Err(ExportError::MoveAssetFailed { + to: $to.to_string(), + from: $from.to_string(), + source: err, + }); } }; } /// Finalizes the export by copying assets. This is very different from the finalization process of normal building. -pub fn finalize_export(target: &Path) -> Result<()> { +pub fn finalize_export(target: &Path) -> Result<(), ExportError> { // Move the `pkg/` directory into `dist/pkg/` as usual let pkg_dir = target.join("dist/pkg"); if pkg_dir.exists() { if let Err(err) = fs::remove_dir_all(&pkg_dir) { - bail!(ErrorKind::MovePkgDirFailed(err.to_string())); + return Err(ExecutionError::MovePkgDirFailed { source: err }.into()); } } // The `fs::rename()` function will fail on Windows if the destination already exists, so this should work (we've just deleted it as per https://github.com/rust-lang/rust/issues/31301#issuecomment-177117325) if let Err(err) = fs::rename(target.join("pkg"), target.join("dist/pkg")) { - bail!(ErrorKind::MovePkgDirFailed(err.to_string())); + return Err(ExecutionError::MovePkgDirFailed { source: err }.into()); } // Copy files over (the directory structure should already exist from exporting the pages) @@ -62,17 +62,19 @@ pub fn finalize_export(target: &Path) -> Result<()> { ); // Copy any JS snippets over (if the directory doesn't exist though, don't do anything) // This takes a target of the `dist/` directory, and then extends on that - fn copy_snippets(ext: &str, parent: &Path) -> Result<()> { + fn copy_snippets(ext: &str, parent: &Path) -> Result<(), ExportError> { // We read from the parent directory (`.perseus`), extended with `ext` if let Ok(snippets) = fs::read_dir(&parent.join(ext)) { for file in snippets { let path = match file { Ok(file) => file.path(), - Err(err) => bail!(ErrorKind::MoveExportAssetFailed( - "js snippet".to_string(), - "exportable js snippet".to_string(), - err.to_string() - )), + Err(err) => { + return Err(ExportError::MoveAssetFailed { + from: "js snippet".to_string(), + to: "exportable js snippet".to_string(), + source: err, + }) + } }; // Recurse on any directories and copy any files if path.is_dir() { @@ -101,8 +103,8 @@ pub fn finalize_export(target: &Path) -> Result<()> { dir_tree, filename )); // Create the directory structure needed for this - if fs::create_dir_all(&final_dir_tree).is_err() { - bail!(ErrorKind::ExportDirStructureFailed); + if let Err(err) = fs::create_dir_all(&final_dir_tree) { + return Err(ExportError::DirStructureCreationFailed { source: err }); } copy_file!( path.to_str().unwrap(), @@ -128,10 +130,13 @@ pub fn export_internal( dir: PathBuf, spinners: &MultiProgress, num_steps: u8, -) -> Result<( - ThreadHandle Result, Result>, - ThreadHandle Result, Result>, -)> { +) -> Result< + ( + ThreadHandle Result, Result>, + ThreadHandle Result, Result>, + ), + ExportError, +> { let target = dir.join(".perseus"); // Exporting pages message @@ -186,19 +191,19 @@ pub fn export_internal( } /// Builds the subcrates to get a directory that we can serve. Returns an exit code. -pub fn export(dir: PathBuf, _prog_args: &[String]) -> Result { +pub fn export(dir: PathBuf, _prog_args: &[String]) -> Result { let spinners = MultiProgress::new(); let (ep_thread, wb_thread) = export_internal(dir.clone(), &spinners, 2)?; let ep_res = ep_thread .join() - .map_err(|_| ErrorKind::ThreadWaitFailed)??; + .map_err(|_| ExecutionError::ThreadWaitFailed)??; if ep_res != 0 { return Ok(ep_res); } let wb_res = wb_thread .join() - .map_err(|_| ErrorKind::ThreadWaitFailed)??; + .map_err(|_| ExecutionError::ThreadWaitFailed)??; if wb_res != 0 { return Ok(wb_res); } diff --git a/packages/perseus-cli/src/lib.rs b/packages/perseus-cli/src/lib.rs index d39600a30d..73c4016775 100644 --- a/packages/perseus-cli/src/lib.rs +++ b/packages/perseus-cli/src/lib.rs @@ -54,40 +54,37 @@ pub use serve::serve; /// Deletes a corrupted '.perseus/' directory. This will be called on certain error types that would leave the user with a half-finished /// product, which is better to delete for safety and sanity. -pub fn delete_bad_dir(dir: PathBuf) -> Result<()> { +pub fn delete_bad_dir(dir: PathBuf) -> Result<(), PrepError> { let mut target = dir; target.extend([".perseus"]); // We'll only delete the directory if it exists, otherwise we're fine if target.exists() { if let Err(err) = fs::remove_dir_all(&target) { - bail!(ErrorKind::RemoveBadDirFailed( - target.to_str().map(|s| s.to_string()), - err.to_string() - )) + return Err(PrepError::RemoveBadDirFailed { source: err }); } } Ok(()) } /// Deletes build artifacts in `.perseus/dist/static` or `.perseus/dist/pkg` and replaces the directory. -pub fn delete_artifacts(dir: PathBuf, dir_to_remove: &str) -> Result<()> { +pub fn delete_artifacts(dir: PathBuf, dir_to_remove: &str) -> Result<(), ExecutionError> { let mut target = dir; target.extend([".perseus", "dist", dir_to_remove]); // We'll only delete the directory if it exists, otherwise we're fine if target.exists() { if let Err(err) = fs::remove_dir_all(&target) { - bail!(ErrorKind::RemoveArtifactsFailed( - target.to_str().map(|s| s.to_string()), - err.to_string() - )) + return Err(ExecutionError::RemoveArtifactsFailed { + target: target.to_str().map(|s| s.to_string()), + source: err, + }); } } // No matter what, it's gone now, so recreate it if let Err(err) = fs::create_dir(&target) { - bail!(ErrorKind::RemoveArtifactsFailed( - target.to_str().map(|s| s.to_string()), - err.to_string() - )) + return Err(ExecutionError::RemoveArtifactsFailed { + target: target.to_str().map(|s| s.to_string()), + source: err, + }); } Ok(()) diff --git a/packages/perseus-cli/src/prepare.rs b/packages/perseus-cli/src/prepare.rs index a541bb0ee0..9ac2a5111d 100644 --- a/packages/perseus-cli/src/prepare.rs +++ b/packages/perseus-cli/src/prepare.rs @@ -17,7 +17,7 @@ const SUBCRATES: Dir = include_dir!("./.perseus"); /// Prepares the user's project by copying in the `.perseus/` subcrates. We use these subcrates to do all the building/serving, we just /// have to execute the right commands in the CLI. We can essentially treat the subcrates themselves as a blackbox of just a folder. -pub fn prepare(dir: PathBuf) -> Result<()> { +pub fn prepare(dir: PathBuf) -> Result<(), PrepError> { // The location in the target directory at which we'll put the subcrates let mut target = dir; target.extend([".perseus"]); @@ -30,17 +30,17 @@ pub fn prepare(dir: PathBuf) -> Result<()> { } else { // Write the stored directory to that location, creating the directory first if let Err(err) = fs::create_dir(&target) { - bail!(ErrorKind::ExtractionFailed( - target.to_str().map(|s| s.to_string()), - err.to_string() - )) + return Err(PrepError::ExtractionFailed { + target_dir: target.to_str().map(|s| s.to_string()), + source: err, + }); } // Notably, this function will not do anything or tell us if the directory already exists... if let Err(err) = extract_dir(SUBCRATES, &target) { - bail!(ErrorKind::ExtractionFailed( - target.to_str().map(|s| s.to_string()), - err.to_string() - )) + return Err(PrepError::ExtractionFailed { + target_dir: target.to_str().map(|s| s.to_string()), + source: err, + }); } // Use the current version of this crate (and thus all Perseus crates) to replace the relative imports // That way everything works in dev and in prod on another system! @@ -54,27 +54,25 @@ pub fn prepare(dir: PathBuf) -> Result<()> { let mut server_manifest = target.clone(); server_manifest.extend(["server", "Cargo.toml"]); let root_manifest_contents = fs::read_to_string(&root_manifest_pkg).map_err(|err| { - ErrorKind::ManifestUpdateFailed( - root_manifest_pkg.to_str().map(|s| s.to_string()), - err.to_string(), - ) + PrepError::ManifestUpdateFailed { + target_dir: root_manifest_pkg.to_str().map(|s| s.to_string()), + source: err, + } })?; let server_manifest_contents = fs::read_to_string(&server_manifest_pkg).map_err(|err| { - ErrorKind::ManifestUpdateFailed( - server_manifest_pkg.to_str().map(|s| s.to_string()), - err.to_string(), - ) + PrepError::ManifestUpdateFailed { + target_dir: server_manifest_pkg.to_str().map(|s| s.to_string()), + source: err, + } })?; // Get the name of the user's crate (which the subcrates depend on) // We assume they're running this in a folder with a Cargo.toml... let user_manifest = Manifest::from_path("./Cargo.toml") - .map_err(|err| ErrorKind::GetUserManifestFailed(err.to_string()))?; + .map_err(|err| PrepError::GetUserManifestFailed { source: err })?; let user_crate_name = user_manifest.package; let user_crate_name = match user_crate_name { Some(package) => package.name, - None => bail!(ErrorKind::GetUserManifestFailed( - "no '[package]' section in manifest".to_string() - )), + None => return Err(PrepError::MalformedUserManifest), }; // Update the name of the user's crate (Cargo needs more than just a path and an alias) // Also create a workspace so the subcrates share a `target/` directory (speeds up builds) @@ -98,16 +96,16 @@ pub fn prepare(dir: PathBuf) -> Result<()> { // Write the updated manifests back if let Err(err) = fs::write(&root_manifest, updated_root_manifest) { - bail!(ErrorKind::ManifestUpdateFailed( - root_manifest.to_str().map(|s| s.to_string()), - err.to_string() - )) + return Err(PrepError::ManifestUpdateFailed { + target_dir: root_manifest.to_str().map(|s| s.to_string()), + source: err, + }); } if let Err(err) = fs::write(&server_manifest, updated_server_manifest) { - bail!(ErrorKind::ManifestUpdateFailed( - server_manifest.to_str().map(|s| s.to_string()), - err.to_string() - )) + return Err(PrepError::ManifestUpdateFailed { + target_dir: server_manifest.to_str().map(|s| s.to_string()), + source: err, + }); } // If we aren't already gitignoring the subcrates, update .gitignore to do so @@ -122,11 +120,11 @@ pub fn prepare(dir: PathBuf) -> Result<()> { .open(".gitignore"); let mut file = match file { Ok(file) => file, - Err(err) => bail!(ErrorKind::GitignoreUpdateFailed(err.to_string())), + Err(err) => return Err(PrepError::GitignoreUpdateFailed { source: err }), }; // Check for errors with appending to the file if let Err(err) = file.write_all(b"\n.perseus/") { - bail!(ErrorKind::GitignoreUpdateFailed(err.to_string())) + return Err(PrepError::GitignoreUpdateFailed { source: err }); } Ok(()) } @@ -135,7 +133,7 @@ pub fn prepare(dir: PathBuf) -> Result<()> { /// Checks if the user has the necessary prerequisites on their system (i.e. `cargo` and `wasm-pack`). These can all be checked /// by just trying to run their binaries and looking for errors. If the user has other paths for these, they can define them under the /// environment variables `PERSEUS_CARGO_PATH` and `PERSEUS_WASM_PACK_PATH`. -pub fn check_env() -> Result<()> { +pub fn check_env() -> Result<(), PrepError> { // We'll loop through each prerequisite executable to check their existence // If the spawn returns an error, it's considered not present, success means presence let prereq_execs = vec![ @@ -155,11 +153,11 @@ pub fn check_env() -> Result<()> { let res = Command::new(&exec.0).output(); // Any errors are interpreted as meaning that the user doesn't have the prerequisite installed properly. if let Err(err) = res { - bail!(ErrorKind::PrereqFailed( - exec.1.to_string(), - exec.2.to_string(), - err.to_string() - )) + return Err(PrepError::PrereqNotPresent { + cmd: exec.1.to_string(), + env_var: exec.2.to_string(), + source: err, + }); } } diff --git a/packages/perseus-cli/src/serve.rs b/packages/perseus-cli/src/serve.rs index 0e81187ba8..1d867254c6 100644 --- a/packages/perseus-cli/src/serve.rs +++ b/packages/perseus-cli/src/serve.rs @@ -19,7 +19,7 @@ macro_rules! handle_exit_code { ($code:expr) => {{ let (stdout, stderr, code) = $code; if code != 0 { - return $crate::errors::Result::Ok(code); + return ::std::result::Result::Ok(code); } (stdout, stderr) }}; @@ -34,7 +34,10 @@ fn build_server( spinners: &MultiProgress, did_build: bool, exec: Arc>, -) -> Result Result, Result>> { +) -> Result< + ThreadHandle Result, Result>, + ExecutionError, +> { let num_steps = match did_build { true => 4, false => 2, @@ -55,58 +58,61 @@ fn build_server( let sb_spinner = spinners.insert(num_steps - 1, ProgressBar::new_spinner()); let sb_spinner = cfg_spinner(sb_spinner, &sb_msg); let sb_target = target; - let sb_thread = spawn_thread(move || { - let (stdout, _stderr) = handle_exit_code!(run_stage( - vec![&format!( - // This sets Cargo to tell us everything, including the executable path to the server - "{} build --message-format json", - env::var("PERSEUS_CARGO_PATH").unwrap_or_else(|_| "cargo".to_string()) - )], - &sb_target, - &sb_spinner, - &sb_msg - )?); - - let msgs: Vec<&str> = stdout.trim().split('\n').collect(); - // If we got to here, the exit code was 0 and everything should've worked - // The last message will just tell us that the build finished, the second-last one will tell us the executable path - let msg = msgs.get(msgs.len() - 2); - let msg = match msg { - // We'll parse it as a Serde `Value`, we don't need to know everything that's in there - Some(msg) => serde_json::from_str::(msg) - .map_err(|err| ErrorKind::GetServerExecutableFailed(err.to_string()))?, - None => bail!(ErrorKind::GetServerExecutableFailed( - "expected second-last message, none existed (too few messages)".to_string() - )), - }; - let server_exec_path = msg.get("executable"); - let server_exec_path = match server_exec_path { + let sb_thread = + spawn_thread(move || { + let (stdout, _stderr) = handle_exit_code!(run_stage( + vec![&format!( + // This sets Cargo to tell us everything, including the executable path to the server + "{} build --message-format json", + env::var("PERSEUS_CARGO_PATH").unwrap_or_else(|_| "cargo".to_string()) + )], + &sb_target, + &sb_spinner, + &sb_msg + )?); + + let msgs: Vec<&str> = stdout.trim().split('\n').collect(); + // If we got to here, the exit code was 0 and everything should've worked + // The last message will just tell us that the build finished, the second-last one will tell us the executable path + let msg = msgs.get(msgs.len() - 2); + let msg = match msg { + // We'll parse it as a Serde `Value`, we don't need to know everything that's in there + Some(msg) => serde_json::from_str::(msg) + .map_err(|err| ExecutionError::GetServerExecutableFailed { source: err })?, + None => return Err(ExecutionError::ServerExectutableMsgNotFound), + }; + let server_exec_path = msg.get("executable"); + let server_exec_path = match server_exec_path { // We'll parse it as a Serde `Value`, we don't need to know everything that's in there Some(server_exec_path) => match server_exec_path.as_str() { Some(server_exec_path) => server_exec_path, - None => bail!(ErrorKind::GetServerExecutableFailed( - "expected 'executable' field to be string".to_string() - )), + None => return Err(ExecutionError::ParseServerExecutableFailed { + err: "expected 'executable' field to be string".to_string() + }), }, - None => bail!(ErrorKind::GetServerExecutableFailed( - "expected 'executable' field in JSON map in second-last message, not present" + None => return Err(ExecutionError::ParseServerExecutableFailed { + err: "expected 'executable' field in JSON map in second-last message, not present" .to_string() - )), + }), }; - // And now the main thread needs to know about this - let mut exec_val = exec.lock().unwrap(); - *exec_val = server_exec_path.to_string(); + // And now the main thread needs to know about this + let mut exec_val = exec.lock().unwrap(); + *exec_val = server_exec_path.to_string(); - Ok(0) - }); + Ok(0) + }); Ok(sb_thread) } /// Runs the server at the given path, handling any errors therewith. This will likely be a black hole until the user manually terminates /// the process. -fn run_server(exec: Arc>, dir: PathBuf, did_build: bool) -> Result { +fn run_server( + exec: Arc>, + dir: PathBuf, + did_build: bool, +) -> Result { let target = dir.join(".perseus/server"); let num_steps = match did_build { true => 4, @@ -116,10 +122,10 @@ fn run_server(exec: Arc>, dir: PathBuf, did_build: bool) -> Result // First off, handle any issues with the executable path let exec_val = exec.lock().unwrap(); if exec_val.is_empty() { - bail!(ErrorKind::GetServerExecutableFailed( - "mutex value empty, implies uncaught thread termination (please report this as a bug)" + return Err(ExecutionError::ParseServerExecutableFailed { + err: "mutex value empty, implies uncaught thread termination (please report this as a bug)" .to_string() - )) + }); } let server_exec_path = (*exec_val).to_string(); @@ -130,13 +136,16 @@ fn run_server(exec: Arc>, dir: PathBuf, did_build: bool) -> Result .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() - .map_err(|err| ErrorKind::CmdExecFailed(server_exec_path, err.to_string()))?; + .map_err(|err| ExecutionError::CmdExecFailed { + cmd: server_exec_path, + source: err, + })?; // Figure out what host/port the app will be live on let host = env::var("HOST").unwrap_or_else(|_| "localhost".to_string()); let port = env::var("PORT") .unwrap_or_else(|_| "8080".to_string()) .parse::() - .map_err(|err| ErrorKind::PortNotNumber(err.to_string()))?; + .map_err(|err| ExecutionError::PortNotNumber { source: err })?; // Give the user a nice informational message println!( " {} {} Your app is now live on ! To change this, re-run this command with different settings of the HOST/PORT environment variables.", @@ -164,7 +173,7 @@ fn run_server(exec: Arc>, dir: PathBuf, did_build: bool) -> Result } /// Builds the subcrates to get a directory that we can serve and then serves it. -pub fn serve(dir: PathBuf, prog_args: &[String]) -> Result { +pub fn serve(dir: PathBuf, prog_args: &[String]) -> Result { let spinners = MultiProgress::new(); // TODO support watching files let did_build = !prog_args.contains(&"--no-build".to_string()); @@ -178,10 +187,10 @@ pub fn serve(dir: PathBuf, prog_args: &[String]) -> Result { let (sg_thread, wb_thread) = build_internal(dir.clone(), &spinners, 4)?; let sg_res = sg_thread .join() - .map_err(|_| ErrorKind::ThreadWaitFailed)??; + .map_err(|_| ExecutionError::ThreadWaitFailed)??; let wb_res = wb_thread .join() - .map_err(|_| ErrorKind::ThreadWaitFailed)??; + .map_err(|_| ExecutionError::ThreadWaitFailed)??; if sg_res != 0 { return Ok(sg_res); } else if wb_res != 0 { @@ -191,7 +200,7 @@ pub fn serve(dir: PathBuf, prog_args: &[String]) -> Result { // Handle errors from the server building let sb_res = sb_thread .join() - .map_err(|_| ErrorKind::ThreadWaitFailed)??; + .map_err(|_| ExecutionError::ThreadWaitFailed)??; if sb_res != 0 { return Ok(sb_res); } From a1bfc6ea786b64a10b0499a10d40b10b56f66a52 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 28 Sep 2021 07:40:59 +1000 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20migrated?= =?UTF-8?q?=20core=20to=20new=20error=20systems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/perseus/Cargo.toml | 2 +- packages/perseus/src/build.rs | 20 +- .../src/client_translations_manager.rs | 25 +- packages/perseus/src/config_manager.rs | 79 +++--- packages/perseus/src/decode_time_str.rs | 8 +- packages/perseus/src/errors.rs | 253 ++++++++++-------- packages/perseus/src/export.rs | 19 +- packages/perseus/src/serve.rs | 33 ++- packages/perseus/src/shell.rs | 52 ++-- packages/perseus/src/template.rs | 117 ++++---- packages/perseus/src/translations_manager.rs | 111 +++++--- packages/perseus/src/translator/errors.rs | 63 +++-- packages/perseus/src/translator/fluent.rs | 72 +++-- 13 files changed, 491 insertions(+), 363 deletions(-) diff --git a/packages/perseus/Cargo.toml b/packages/perseus/Cargo.toml index feb6285e25..a3eadf589a 100644 --- a/packages/perseus/Cargo.toml +++ b/packages/perseus/Cargo.toml @@ -23,7 +23,7 @@ wasm-bindgen-futures = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" typetag = "0.1" -error-chain = "0.12" +thiserror = "1" futures = "0.3" console_error_panic_hook = "0.1.6" urlencoding = "2.1" diff --git a/packages/perseus/src/build.rs b/packages/perseus/src/build.rs index 4c16d29a17..e20589375e 100644 --- a/packages/perseus/src/build.rs +++ b/packages/perseus/src/build.rs @@ -19,7 +19,7 @@ pub async fn build_template( translator: Rc, config_manager: &impl ConfigManager, exporting: bool, -) -> Result<(Vec, bool)> { +) -> Result<(Vec, bool), ServerError> { let mut single_page = false; let template_path = template.get_path(); @@ -31,7 +31,10 @@ pub async fn build_template( // We check amalgamation as well because it involves request state, even if that wasn't provided template.can_amalgamate_states()) { - bail!(ErrorKind::TemplateNotExportable(template_path.clone())) + return Err(ExportError::TemplateNotExportable { + template_name: template_path.clone(), + } + .into()); } // Handle static path generation @@ -129,7 +132,7 @@ async fn build_template_and_get_cfg( translator: Rc, config_manager: &impl ConfigManager, exporting: bool, -) -> Result> { +) -> Result, ServerError> { let mut render_cfg = HashMap::new(); let template_root_path = template.get_path(); let is_incremental = template.uses_incremental(); @@ -167,7 +170,7 @@ pub async fn build_templates_for_locale( translator_raw: Translator, config_manager: &impl ConfigManager, exporting: bool, -) -> Result<()> { +) -> Result<(), ServerError> { let translator = Rc::new(translator_raw); // The render configuration stores a list of pages to the root paths of their templates let mut render_cfg: HashMap = HashMap::new(); @@ -187,7 +190,10 @@ pub async fn build_templates_for_locale( } config_manager - .write("render_conf.json", &serde_json::to_string(&render_cfg)?) + .write( + "render_conf.json", + &serde_json::to_string(&render_cfg).unwrap(), + ) .await?; Ok(()) @@ -200,7 +206,7 @@ async fn build_templates_and_translator_for_locale( config_manager: &impl ConfigManager, translations_manager: &impl TranslationsManager, exporting: bool, -) -> Result<()> { +) -> Result<(), ServerError> { let translator = translations_manager .get_translator_for_locale(locale) .await?; @@ -217,7 +223,7 @@ pub async fn build_app( config_manager: &impl ConfigManager, translations_manager: &impl TranslationsManager, exporting: bool, -) -> Result<()> { +) -> Result<(), ServerError> { let locales = locales.get_all(); let mut futs = Vec::new(); diff --git a/packages/perseus/src/client_translations_manager.rs b/packages/perseus/src/client_translations_manager.rs index 4190e216ff..01c6948a40 100644 --- a/packages/perseus/src/client_translations_manager.rs +++ b/packages/perseus/src/client_translations_manager.rs @@ -23,7 +23,10 @@ impl ClientTranslationsManager { } /// Gets an `Rc` for the given locale. This will use the internally cached `Translator` if possible, and will otherwise /// fetch the translations from the server. This needs mutability because it will modify its internal cache if necessary. - pub async fn get_translator_for_locale(&mut self, locale: &str) -> Result> { + pub async fn get_translator_for_locale( + &mut self, + locale: &str, + ) -> Result, ClientError> { // Check if we've already cached if self.cached_translator.is_some() && self.cached_translator.as_ref().unwrap().get_locale() == locale @@ -44,7 +47,11 @@ impl ClientTranslationsManager { match translator { Ok(translator) => translator, Err(err) => { - bail!(ErrorKind::AssetSerFailed(asset_url, err.to_string())) + return Err(FetchError::SerFailed { + url: asset_url, + source: err.into(), + } + .into()) } } } @@ -54,12 +61,10 @@ impl ClientTranslationsManager { locale ), }, - Err(err) => match err.kind() { - ErrorKind::AssetNotOk(url, status, err) => bail!(ErrorKind::AssetNotOk( - url.to_string(), - *status, - err.to_string() - )), + Err(err) => match err { + not_ok_err @ ClientError::FetchError(FetchError::NotOk { .. }) => { + return Err(not_ok_err) + } // No other errors should be returned _ => panic!("expected 'AssetNotOk' error, found other unacceptable error"), }, @@ -76,7 +81,9 @@ impl ClientTranslationsManager { // Now return that Ok(Rc::clone(self.cached_translator.as_ref().unwrap())) } else { - bail!(ErrorKind::LocaleNotSupported(locale.to_string())) + Err(ClientError::LocaleNotSupported { + locale: locale.to_string(), + }) } } } diff --git a/packages/perseus/src/config_manager.rs b/packages/perseus/src/config_manager.rs index c899c7fc83..95cffc992b 100644 --- a/packages/perseus/src/config_manager.rs +++ b/packages/perseus/src/config_manager.rs @@ -2,38 +2,38 @@ // At simplest, this is just a filesystem interface, but it's more likely to be a CMS in production // This has its own error management logic because the user may implement it separately -use error_chain::{bail, error_chain}; -use std::fs; +use thiserror::Error; -// This has no foreign links because everything to do with config management should be isolated and generic -error_chain! { - errors { - /// For when data wasn't found. - NotFound(name: String) { - description("data not found") - display("data with name '{}' not found", name) - } - /// For when data couldn't be read for some generic reason. - ReadFailed(name: String, err: String) { - description("data couldn't be read") - display("data with name '{}' couldn't be read, error was '{}'", name, err) - } - /// For when data couldn't be written for some generic reason. - WriteFailed(name: String, err: String) { - description("data couldn't be written") - display("data with name '{}' couldn't be written, error was '{}'", name, err) - } - } +/// Errors that can occur in a config manager. +#[allow(missing_docs)] +#[derive(Error, Debug)] +pub enum ConfigManagerError { + #[error("asset '{name}' not found")] + NotFound { name: String }, + #[error("asset '{name}' couldn't be read")] + ReadFailed { + name: String, + #[source] + source: Box, + }, + #[error("asset '{name}' couldn't be written")] + WriteFailed { + name: String, + #[source] + source: Box, + }, } +use std::fs; + /// A trait for systems that manage where to put configuration files. At simplest, we'll just write them to static files, but they're /// more likely to be stored on a CMS. #[async_trait::async_trait] pub trait ConfigManager: Clone { /// Reads data from the named asset. - async fn read(&self, name: &str) -> Result; + async fn read(&self, name: &str) -> Result; /// Writes data to the named asset. This will create a new asset if one doesn't exist already. - async fn write(&self, name: &str, content: &str) -> Result<()>; + async fn write(&self, name: &str, content: &str) -> Result<(), ConfigManagerError>; } /// The default config manager. This will store static files in the specified location on disk. This should be suitable for nearly all @@ -52,26 +52,39 @@ impl FsConfigManager { } #[async_trait::async_trait] impl ConfigManager for FsConfigManager { - async fn read(&self, name: &str) -> Result { + async fn read(&self, name: &str) -> Result { let asset_path = format!("{}/{}", self.root_path, name); match fs::metadata(&asset_path) { - Ok(_) => fs::read_to_string(&asset_path) - .map_err(|err| ErrorKind::ReadFailed(asset_path, err.to_string()).into()), + Ok(_) => { + fs::read_to_string(&asset_path).map_err(|err| ConfigManagerError::ReadFailed { + name: asset_path, + source: err.into(), + }) + } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - bail!(ErrorKind::NotFound(asset_path)) + return Err(ConfigManagerError::NotFound { name: asset_path }) + } + Err(err) => { + return Err(ConfigManagerError::ReadFailed { + name: asset_path, + source: err.into(), + }) } - Err(err) => bail!(ErrorKind::ReadFailed(name.to_string(), err.to_string())), } } // This creates a directory structure as necessary - async fn write(&self, name: &str, content: &str) -> Result<()> { + async fn write(&self, name: &str, content: &str) -> Result<(), ConfigManagerError> { let asset_path = format!("{}/{}", self.root_path, name); let mut dir_tree: Vec<&str> = asset_path.split('/').collect(); dir_tree.pop(); - fs::create_dir_all(dir_tree.join("/")) - .map_err(|err| ErrorKind::WriteFailed(asset_path.clone(), err.to_string()))?; - fs::write(&asset_path, content) - .map_err(|err| ErrorKind::WriteFailed(asset_path, err.to_string()).into()) + fs::create_dir_all(dir_tree.join("/")).map_err(|err| ConfigManagerError::WriteFailed { + name: asset_path.clone(), + source: err.into(), + })?; + fs::write(&asset_path, content).map_err(|err| ConfigManagerError::WriteFailed { + name: asset_path, + source: err.into(), + }) } } diff --git a/packages/perseus/src/decode_time_str.rs b/packages/perseus/src/decode_time_str.rs index 60e43a85b6..f82a4bad95 100644 --- a/packages/perseus/src/decode_time_str.rs +++ b/packages/perseus/src/decode_time_str.rs @@ -13,7 +13,7 @@ use chrono::{Duration, Utc}; /// - w: week, /// - M: month (30 days used here, 12M ≠ 1y!), /// - y: year (365 days always, leap years ignored, if you want them add them as days) -pub fn decode_time_str(time_str: &str) -> Result { +pub fn decode_time_str(time_str: &str) -> Result { let mut duration_after_current = Duration::zero(); // Get the current datetime since Unix epoch, we'll add to that let current = Utc::now(); @@ -36,7 +36,11 @@ pub fn decode_time_str(time_str: &str) -> Result { 'w' => Duration::weeks(interval_length), 'M' => Duration::days(interval_length * 30), // Multiplying the number of months by 30 days (assumed length of a month) 'y' => Duration::days(interval_length * 365), // Multiplying the number of years by 365 days (assumed length of a year) - c => bail!(ErrorKind::InvalidDatetimeIntervalIndicator(c.to_string())), + c => { + return Err(BuildError::InvalidDatetimeIntervalIndicator { + indicator: c.to_string(), + }) + } }; duration_after_current = duration_after_current + duration; // Reset that working variable diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index 04fdbb2dfb..1fafd92d7a 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -1,132 +1,147 @@ #![allow(missing_docs)] -pub use error_chain::bail; -use error_chain::error_chain; +use crate::config_manager::ConfigManagerError; +use crate::translations_manager::TranslationsManagerError; +use thiserror::Error; -/// Defines who caused an ambiguous error message so we can reliably create an HTTP status code. Specific status codes may be provided -/// in either case, or the defaults (400 for client, 500 for server) will be used. -#[derive(Debug)] -pub enum ErrorCause { - Client(Option), - Server(Option), +/// All errors that can be returned from this crate. +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + ClientError(#[from] FetchError), + #[error(transparent)] + ServerError(#[from] ServerError), } -// The `error_chain` setup for the whole crate -error_chain! { - // The custom errors for this crate (very broad) - errors { - /// For indistinct JavaScript errors (potentially sensitive, but only generated on the client-side). - JsErr(err: String) { - description("an error occurred while interfacing with javascript") - display("the following error occurred while interfacing with javascript: {:?}", err) - } - /// For when a fetched URL didn't return a string, which it must. - AssetNotString(url: String) { - description("the fetched asset wasn't a string") - display("the fetched asset at '{}' wasn't a string", url) - } - /// For when the server returned a non-200 error code (not including 404, that's handled separately). - AssetNotOk(url: String, status: u16, err: String) { - description("the asset couldn't be fecthed with a 200 OK") - display("the asset at '{}' returned status code '{}' with payload '{}'", url, status, err) - } - /// For when the server returned an asset that was 200 but couldn't be serialized properly. This is the server's fault, and - /// should generate a 500 status code at presentation. - AssetSerFailed(url: String, err: String) { - description("the asset couldn't be properly serialized") - display("the asset at '{}' was successfully fetched, but couldn't be serialized with error '{}'", url, err) - } - /// For when the user requested an unsupported locale. This should generate a 404 at presentation. - LocaleNotSupported(locale: String) { - description("the given locale is not supported") - display("the locale '{}' is not supported", locale) - } - - /// For when a necessary template feature was expected but not present. This just pertains to rendering strategies, and shouldn't - /// ever be sensitive. - TemplateFeatureNotEnabled(name: String, feature: String) { - description("a template feature required by a function called was not present") - display("the template '{}' is missing the feature '{}'", name, feature) - } - /// For when a template was using non-exportable features, but the user was trying to export. - TemplateNotExportable(name: String) { - description("attempted to export template with non-exportable features") - display("the template '{}' is using features that cannot be exported (only build state and build paths can be exported, you may wish to build instead)", name) - } - /// For when the HTML shell couldn't be found. - HtmlShellNotFound(path: String, err: String) { - description("html shell not found") - display("html shell couldn't be found at given path '{}', make sure that exists and that you have permission to read from there, error was: '{}'", path, err) - } - /// For when a template couldn't be found while exporting. - TemplateNotFound(path: String) { - description("template not found") - display("template '{}' couldn't be found, please try again", path) - } - /// For when the given path wasn't found, a 404 should never be sensitive. - PageNotFound(path: String) { - description("the requested page was not found") - display("the requested page at path '{}' was not found", path) - } - /// For when the user misconfigured their revalidation length, which should be caught at build time, and hence shouldn't be - /// sensitive. - InvalidDatetimeIntervalIndicator(indicator: String) { - description("invalid indicator in timestring") - display("invalid indicator '{}' in timestring, must be one of: s, m, h, d, w, M, y", indicator) - } - /// For when a template defined both build and request states when it can't amalgamate them sensibly, which indicates a misconfiguration. - /// Revealing the rendering strategies of a template in this way should never be sensitive. Due to the execution context, this - /// doesn't disclose the offending template. - BothStatesDefined { - description("both build and request states were defined for a template when only one or fewer were expected") - display("both build and request states were defined for a template when only one or fewer were expected") - } - /// For when a render function failed. Only request-time functions can generate errors that will be transmitted over the network, - /// so **render functions must not disclose sensitive information in errors**. Other information shouldn't be sensitive. - RenderFnFailed(fn_name: String, template: String, cause: ErrorCause, err_str: String) { - description("error while calling render function") - display("an error caused by '{:?}' occurred while calling render function '{}' on template '{}': '{}'", cause, fn_name, template, err_str) - } - } - links { - ConfigManager(crate::config_manager::Error, crate::config_manager::ErrorKind); - TranslationsManager(crate::translations_manager::Error, crate::translations_manager::ErrorKind); - Translator(crate::translator::errors::Error, crate::translator::errors::ErrorKind); - } - // We work with many external libraries, all of which have their own errors - foreign_links { - Io(::std::io::Error); - Json(::serde_json::Error); - ChronoParse(::chrono::ParseError); - } +/// Errors that can occur in the browser. +#[derive(Error, Debug)] +pub enum ClientError { + #[error("locale '{locale}' is not supported")] + LocaleNotSupported { locale: String }, + /// This converts from a `JsValue` or the like. + #[error("the following error occurred while interfacing with JavaScript: {0}")] + Js(String), + #[error(transparent)] + FetchError(#[from] FetchError), } -pub fn err_to_status_code(err: &Error) -> u16 { - match err.kind() { - // Misconfiguration - ErrorKind::TemplateFeatureNotEnabled(_, _) => 500, - // Bad request - ErrorKind::PageNotFound(_) => 404, - // Misconfiguration - ErrorKind::InvalidDatetimeIntervalIndicator(_) => 500, - // Misconfiguration - ErrorKind::BothStatesDefined => 500, - // Ambiguous, we'll rely on the given cause - ErrorKind::RenderFnFailed(_, _, cause, _) => match cause { +/// Errors that can occur in the build process or while the server is running. +#[derive(Error, Debug)] +pub enum ServerError { + #[error("render function '{fn_name}' in template '{template_name}' failed (cause: {cause:?})")] + RenderFnFailed { + // This is something like `build_state` + fn_name: String, + template_name: String, + cause: ErrorCause, + // This will be triggered by the user's custom render functions, which should be able to have any error type + // TODO figure out custom error types on render functions + #[source] + source: Box, + }, + #[error(transparent)] + ConfigManagerError(#[from] ConfigManagerError), + #[error(transparent)] + TranslationsManagerError(#[from] TranslationsManagerError), + #[error(transparent)] + BuildError(#[from] BuildError), + #[error(transparent)] + ExportError(#[from] ExportError), + #[error(transparent)] + ServeError(#[from] ServeError), +} +/// Converts a server error into an HTTP status code. +pub fn err_to_status_code(err: &ServerError) -> u16 { + match err { + ServerError::ServeError(ServeError::PageNotFound { .. }) => 404, + // Ambiguous (user-generated error), we'll rely on the given cause + ServerError::RenderFnFailed { cause, .. } => match cause { ErrorCause::Client(code) => code.unwrap_or(400), ErrorCause::Server(code) => code.unwrap_or(500), }, - // We shouldn't be generating JS errors on the server... - ErrorKind::JsErr(_) => { - panic!("function 'err_to_status_code' is only intended for server-side usage") - } - // These are nearly always server-induced - ErrorKind::ConfigManager(_) => 500, - ErrorKind::Io(_) => 500, - ErrorKind::ChronoParse(_) => 500, - // JSON errors can be caused by the client, but we don't have enough information - ErrorKind::Json(_) => 500, - // Any other errors go to a 500 + // Any other errors go to a 500, they'll be misconfigurations or internal server errors _ => 500, } } + +/// Errors that can occur while fetching a resource from the server. +#[derive(Error, Debug)] +pub enum FetchError { + #[error("asset fetched from '{url}' wasn't a string")] + NotString { url: String }, + #[error("asset fetched from '{url}' returned status code '{status}' (expected 200)")] + NotOk { + url: String, + status: u16, + // The underlying body of the HTTP error response + err: String, + }, + #[error("asset fetched from '{url}' couldn't be serialized")] + SerFailed { + url: String, + #[source] + source: Box, + }, +} + +/// Errors that can occur while building an app. +#[derive(Error, Debug)] +pub enum BuildError { + #[error("template '{template_name}' is missing feature '{feature_name}' (required due to its properties)")] + TemplateFeatureNotEnabled { + template_name: String, + feature_name: String, + }, + #[error("html shell couldn't be found at '{path}'")] + HtmlShellNotFound { + path: String, + #[source] + source: std::io::Error, + }, + #[error( + "invalid indicator '{indicator}' in time string (must be one of: s, m, h, d, w, M, y)" + )] + InvalidDatetimeIntervalIndicator { indicator: String }, + #[error("asset 'render_cfg.json' invalid or corrupted (try cleaning all assets)")] + RenderCfgInvalid { + #[from] + source: serde_json::Error, + }, +} + +/// Errors that can occur while exporting an app to static files. +#[derive(Error, Debug)] +pub enum ExportError { + #[error("template '{template_name}' can't be exported because it depends on strategies that can't be run at build-time (only build state and build paths can be use din exportable templates)")] + TemplateNotExportable { template_name: String }, + #[error("template '{template_name}' wasn't found in built artifacts (run `perseus clean --dist` if this persists)")] + TemplateNotFound { template_name: String }, +} + +/// Errors that can occur while serving an app. These are integration-agnostic. +#[derive(Error, Debug)] +pub enum ServeError { + #[error("page at '{path}' not found")] + PageNotFound { path: String }, + #[error("both build and request states were defined for a template when only one or fewer were expected (should it be able to amalgamate states?)")] + BothStatesDefined, + #[error("couldn't parse revalidation datetime (try cleaning all assets)")] + BadRevalidate { + #[from] + source: chrono::ParseError, + }, +} + +/// Defines who caused an ambiguous error message so we can reliably create an HTTP status code. Specific status codes may be provided +/// in either case, or the defaults (400 for client, 500 for server) will be used. +#[derive(Debug)] +pub enum ErrorCause { + Client(Option), + Server(Option), +} + +/// Formats an error to be displayed to a user. This will include a series of indented sources. +// TODO add a source chain etc. +pub fn format_err(err: &impl std::error::Error) -> String { + err.to_string() +} diff --git a/packages/perseus/src/export.rs b/packages/perseus/src/export.rs index 64e7fc85d0..543353a8df 100644 --- a/packages/perseus/src/export.rs +++ b/packages/perseus/src/export.rs @@ -14,7 +14,7 @@ async fn get_static_page_data( path: &str, has_state: bool, config_manager: &impl ConfigManager, -) -> Result { +) -> Result { // Get the partial HTML content and a state to go with it (if applicable) let content = config_manager .read(&format!("static/{}.html", path)) @@ -48,13 +48,15 @@ pub async fn export_app( root_id: &str, config_manager: &impl ConfigManager, translations_manager: &impl TranslationsManager, -) -> Result<()> { +) -> Result<(), ServerError> { // The render configuration acts as a guide here, it tells us exactly what we need to iterate over (no request-side pages!) let render_cfg = get_render_cfg(config_manager).await?; // Get the HTML shell and prepare it by interpolating necessary values - let raw_html_shell = fs::read_to_string(html_shell_path).map_err(|err| { - ErrorKind::HtmlShellNotFound(html_shell_path.to_string(), err.to_string()) - })?; + let raw_html_shell = + fs::read_to_string(html_shell_path).map_err(|err| BuildError::HtmlShellNotFound { + path: html_shell_path.to_string(), + source: err, + })?; let html_shell = prep_html_shell(raw_html_shell, &render_cfg); // Loop over every partial @@ -65,7 +67,12 @@ pub async fn export_app( let template = templates.get(&template_path); let template = match template { Some(template) => template, - None => bail!(ErrorKind::PageNotFound(template_path)), + None => { + return Err(ServeError::PageNotFound { + path: template_path, + } + .into()) + } }; // Create a locale detection file for it if we're using i18n // These just send the app shell, which will perform a redirect as necessary diff --git a/packages/perseus/src/serve.rs b/packages/perseus/src/serve.rs index 32bb5021c8..fb27a8403d 100644 --- a/packages/perseus/src/serve.rs +++ b/packages/perseus/src/serve.rs @@ -28,9 +28,13 @@ pub struct PageData { /// Gets the configuration of how to render each page. pub async fn get_render_cfg( config_manager: &impl ConfigManager, -) -> Result> { +) -> Result, ServerError> { let content = config_manager.read("render_conf.json").await?; - let cfg = serde_json::from_str::>(&content)?; + let cfg = serde_json::from_str::>(&content).map_err(|e| { + // We have to convert it into a build error and then into a server error + let build_err: BuildError = e.into(); + build_err + })?; Ok(cfg) } @@ -39,7 +43,7 @@ pub async fn get_render_cfg( async fn render_build_state( path_encoded: &str, config_manager: &impl ConfigManager, -) -> Result<(String, String, Option)> { +) -> Result<(String, String, Option), ServerError> { // Get the static HTML let html = config_manager .read(&format!("static/{}.html", path_encoded)) @@ -64,7 +68,7 @@ async fn render_request_state( translator: Rc, path: &str, req: Request, -) -> Result<(String, String, Option)> { +) -> Result<(String, String, Option), ServerError> { // Generate the initial state (this may generate an error, but there's no file that can't exist) let state = Some(template.get_request_state(path.to_string(), req).await?); // Use that to render the static HTML @@ -102,7 +106,7 @@ async fn should_revalidate( template: &Template, path_encoded: &str, config_manager: &impl ConfigManager, -) -> Result { +) -> Result { let mut should_revalidate = false; // If it revalidates after a certain period of time, we needd to check that BEFORE the custom logic if template.revalidates_with_time() { @@ -110,7 +114,11 @@ async fn should_revalidate( let datetime_to_revalidate_str = config_manager .read(&format!("static/{}.revld.txt", path_encoded)) .await?; - let datetime_to_revalidate = DateTime::parse_from_rfc3339(&datetime_to_revalidate_str)?; + let datetime_to_revalidate = DateTime::parse_from_rfc3339(&datetime_to_revalidate_str) + .map_err(|e| { + let serve_err: ServeError = e.into(); + serve_err + })?; // Get the current time (UTC) let now = Utc::now(); @@ -134,7 +142,7 @@ async fn revalidate( path: &str, path_encoded: &str, config_manager: &impl ConfigManager, -) -> Result<(String, String, Option)> { +) -> Result<(String, String, Option), ServerError> { // We need to regenerate and cache this page for future usage (until the next revalidation) let state = Some( template @@ -185,7 +193,7 @@ pub async fn get_page_for_template( req: Request, config_manager: &impl ConfigManager, translations_manager: &impl TranslationsManager, -) -> Result { +) -> Result { // Get a translator for this locale (for sanity we hope the manager is caching) let translator = Rc::new( translations_manager @@ -369,7 +377,7 @@ pub async fn get_page( templates: &TemplateMap, config_manager: &impl ConfigManager, translations_manager: &impl TranslationsManager, -) -> Result { +) -> Result { let mut path = raw_path; // If the path is empty, we're looking for the special `index` page if path.is_empty() { @@ -380,7 +388,12 @@ pub async fn get_page( let template = match template { Some(template) => template, // This shouldn't happen because the client should already have performed checks against the render config, but it's handled anyway - None => bail!(ErrorKind::PageNotFound(path.to_string())), + None => { + return Err(ServeError::PageNotFound { + path: path.to_string(), + } + .into()) + } }; let res = get_page_for_template( diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index 2040ac1375..c1d4202809 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -1,4 +1,5 @@ use crate::error_pages::ErrorPageData; +use crate::errors::format_err; use crate::errors::*; use crate::serve::PageData; use crate::template::Template; @@ -16,8 +17,8 @@ use web_sys::{Element, Request, RequestInit, RequestMode, Response}; /// Fetches the given resource. This should NOT be used by end users, but it's required by the CLI. #[doc(hidden)] -pub async fn fetch(url: &str) -> Result> { - let js_err_handler = |err: JsValue| ErrorKind::JsErr(format!("{:?}", err)); +pub async fn fetch(url: &str) -> Result, ClientError> { + let js_err_handler = |err: JsValue| ClientError::Js(format!("{:?}", err)); let mut opts = RequestInit::new(); opts.method("GET").mode(RequestMode::Cors); @@ -42,17 +43,23 @@ pub async fn fetch(url: &str) -> Result> { let body_str = body.as_string(); let body_str = match body_str { Some(body_str) => body_str, - None => bail!(ErrorKind::AssetNotString(url.to_string())), + None => { + return Err(FetchError::NotString { + url: url.to_string(), + } + .into()) + } }; // Handle non-200 error codes if res.status() == 200 { Ok(Some(body_str)) } else { - bail!(ErrorKind::AssetNotOk( - url.to_string(), - res.status(), - body_str - )) + Err(FetchError::NotOk { + url: url.to_string(), + status: res.status(), + err: body_str, + } + .into()) } } @@ -242,13 +249,13 @@ pub async fn app_shell( let translator = match translator { Ok(translator) => translator, Err(err) => { - // Directly eliminate the HTMl sent in from the server before we render an error page + // Directly eliminate the HTML sent in from the server before we render an error page container_rx_elem.set_inner_html(""); - match err.kind() { + match &err { // These errors happen because we couldn't get a translator, so they certainly don't get one - ErrorKind::AssetNotOk(url, status, _) => return error_pages.render_page(url, status, &err.to_string(), None, &container_rx_elem), - ErrorKind::AssetSerFailed(url, _) => return error_pages.render_page(url, &500, &err.to_string(), None, &container_rx_elem), - ErrorKind::LocaleNotSupported(locale) => return error_pages.render_page(&format!("/{}/...", locale), &404, &err.to_string(),None, &container_rx_elem), + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.render_page(url, status, &format_err(&err), None, &container_rx_elem), + ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.render_page(url, &500, &format_err(&err), None, &container_rx_elem), + ClientError::LocaleNotSupported { .. } => return error_pages.render_page(&format!("/{}/...", locale), &404, &format_err(&err), None, &container_rx_elem), // No other errors should be returned _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") } @@ -323,11 +330,11 @@ pub async fn app_shell( .await; let translator = match translator { Ok(translator) => translator, - Err(err) => match err.kind() { + Err(err) => match &err { // These errors happen because we couldn't get a translator, so they certainly don't get one - ErrorKind::AssetNotOk(url, status, _) => return error_pages.render_page(url, status, &err.to_string(), None, &container_rx_elem), - ErrorKind::AssetSerFailed(url, _) => return error_pages.render_page(url, &500, &err.to_string(), None, &container_rx_elem), - ErrorKind::LocaleNotSupported(locale) => return error_pages.render_page(&format!("/{}/...", locale), &404, &err.to_string(),None, &container_rx_elem), + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.render_page(url, status, &format_err(&err), None, &container_rx_elem), + ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.render_page(url, &500, &format_err(&err), None, &container_rx_elem), + ClientError::LocaleNotSupported { locale } => return error_pages.render_page(&format!("/{}/...", locale), &404, &format_err(&err), None, &container_rx_elem), // No other errors should be returned _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") } @@ -361,15 +368,10 @@ pub async fn app_shell( &container_rx_elem, ), }, - Err(err) => match err.kind() { + Err(err) => match &err { // No translators ready yet - ErrorKind::AssetNotOk(url, status, _) => error_pages.render_page( - url, - status, - &err.to_string(), - None, - &container_rx_elem, - ), + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => error_pages + .render_page(url, status, &format_err(&err), None, &container_rx_elem), // No other errors should be returned _ => panic!("expected 'AssetNotOk' error, found other unacceptable error"), }, diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index aafedb5240..d2bb1c56d2 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -41,9 +41,9 @@ impl States { } /// Gets the only defined state if only one is defined. If no states are defined, this will just return `None`. If both are defined, /// this will return an error. - pub fn get_defined(&self) -> Result> { + pub fn get_defined(&self) -> Result, ServeError> { if self.both_defined() { - bail!(ErrorKind::BothStatesDefined) + return Err(ServeError::BothStatesDefined); } if self.build_state.is_some() { @@ -59,6 +59,7 @@ impl States { /// A generic error type that mandates a string error. This sidesteps horrible generics while maintaining DX. pub type StringResult = std::result::Result; /// A generic error type that mandates a string errorr and a statement of causation (client or server) for status code generation. +// TODO let the user use any custom error type pub type StringResultWithCause = std::result::Result; /// A generic return type for asynchronous functions that we need to store in a struct. @@ -242,114 +243,122 @@ impl Template { }) } /// Gets the list of templates that should be prerendered for at build-time. - pub async fn get_build_paths(&self) -> Result> { + pub async fn get_build_paths(&self) -> Result, ServerError> { if let Some(get_build_paths) = &self.get_build_paths { let res = get_build_paths.call().await; match res { Ok(res) => Ok(res), - Err(err) => bail!(ErrorKind::RenderFnFailed( - "get_build_paths".to_string(), - self.get_path(), - ErrorCause::Server(None), - err - )), + Err(err) => Err(ServerError::RenderFnFailed { + fn_name: "get_build_paths".to_string(), + template_name: self.get_path(), + cause: ErrorCause::Server(None), + source: err.into(), + }), } } else { - bail!(ErrorKind::TemplateFeatureNotEnabled( - self.path.clone(), - "build_paths".to_string() - )) + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "build_paths".to_string(), + } + .into()) } } /// Gets the initial state for a template. This needs to be passed the full path of the template, which may be one of those generated by /// `.get_build_paths()`. - pub async fn get_build_state(&self, path: String) -> Result { + pub async fn get_build_state(&self, path: String) -> Result { if let Some(get_build_state) = &self.get_build_state { let res = get_build_state.call(path).await; match res { Ok(res) => Ok(res), - Err((err, cause)) => bail!(ErrorKind::RenderFnFailed( - "get_build_state".to_string(), - self.get_path(), + Err((err, cause)) => Err(ServerError::RenderFnFailed { + fn_name: "get_build_state".to_string(), + template_name: self.get_path(), cause, - err - )), + source: err.into(), + }), } } else { - bail!(ErrorKind::TemplateFeatureNotEnabled( - self.path.clone(), - "build_state".to_string() - )) + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "build_state".to_string(), + } + .into()) } } /// Gets the request-time state for a template. This is equivalent to SSR, and will not be performed at build-time. Unlike /// `.get_build_paths()` though, this will be passed information about the request that triggered the render. Errors here can be caused /// by either the server or the client, so the user must specify an [`ErrorCause`]. - pub async fn get_request_state(&self, path: String, req: Request) -> Result { + pub async fn get_request_state( + &self, + path: String, + req: Request, + ) -> Result { if let Some(get_request_state) = &self.get_request_state { let res = get_request_state.call(path, req).await; match res { Ok(res) => Ok(res), - Err((err, cause)) => bail!(ErrorKind::RenderFnFailed( - "get_request_state".to_string(), - self.get_path(), + Err((err, cause)) => Err(ServerError::RenderFnFailed { + fn_name: "get_request_state".to_string(), + template_name: self.get_path(), cause, - err - )), + source: err.into(), + }), } } else { - bail!(ErrorKind::TemplateFeatureNotEnabled( - self.path.clone(), - "request_state".to_string() - )) + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "request_state".to_string(), + } + .into()) } } /// Amalagmates given request and build states. Errors here can be caused by either the server or the client, so the user must specify /// an [`ErrorCause`]. - pub fn amalgamate_states(&self, states: States) -> Result> { + pub fn amalgamate_states(&self, states: States) -> Result, ServerError> { if let Some(amalgamate_states) = &self.amalgamate_states { let res = amalgamate_states(states); match res { Ok(res) => Ok(res), - Err((err, cause)) => bail!(ErrorKind::RenderFnFailed( - "amalgamate_states".to_string(), - self.get_path(), + Err((err, cause)) => Err(ServerError::RenderFnFailed { + fn_name: "amalgamate_states".to_string(), + template_name: self.get_path(), cause, - err - )), + source: err.into(), + }), } } else { - bail!(ErrorKind::TemplateFeatureNotEnabled( - self.path.clone(), - "request_state".to_string() - )) + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "request_state".to_string(), + } + .into()) } } /// Checks, by the user's custom logic, if this template should revalidate. This function isn't presently parsed anything, but has /// network access etc., and can really do whatever it likes. Errors here can be caused by either the server or the client, so the /// user must specify an [`ErrorCause`]. - pub async fn should_revalidate(&self) -> Result { + pub async fn should_revalidate(&self) -> Result { if let Some(should_revalidate) = &self.should_revalidate { let res = should_revalidate.call().await; match res { Ok(res) => Ok(res), - Err((err, cause)) => bail!(ErrorKind::RenderFnFailed( - "should_revalidate".to_string(), - self.get_path(), + Err((err, cause)) => Err(ServerError::RenderFnFailed { + fn_name: "should_revalidate".to_string(), + template_name: self.get_path(), cause, - err - )), + source: err.into(), + }), } } else { - bail!(ErrorKind::TemplateFeatureNotEnabled( - self.path.clone(), - "should_revalidate".to_string() - )) + Err(BuildError::TemplateFeatureNotEnabled { + template_name: self.path.clone(), + feature_name: "should_revalidate".to_string(), + } + .into()) } } /// Gets the template's headers for the given state. These will be inserted into any successful HTTP responses for this template, /// and they have the power to override. - #[allow(clippy::mutable_key_type)] pub fn get_headers(&self, state: Option) -> HeaderMap { (self.set_headers)(state) } diff --git a/packages/perseus/src/translations_manager.rs b/packages/perseus/src/translations_manager.rs index 85f3b59f86..a6f5454a98 100644 --- a/packages/perseus/src/translations_manager.rs +++ b/packages/perseus/src/translations_manager.rs @@ -2,43 +2,47 @@ // At simplest, this is just a filesystem interface, but it might be something like a database in production // This has its own error management logic because the user may implement it separately +use thiserror::Error; + +/// Errors that can occur in a translations manager. +#[allow(missing_docs)] +#[derive(Error, Debug)] +pub enum TranslationsManagerError { + #[error("translations not found for locale '{locale}'")] + NotFound { locale: String }, + #[error("translations for locale '{locale}' couldn't be read")] + ReadFailed { + locale: String, + #[source] + source: Box, + }, + #[error("translations for locale '{locale}' couldn't be serialized into translator")] + SerializationFailed { + locale: String, + #[source] + source: Box, + }, +} + use crate::Translator; -pub use error_chain::bail; -use error_chain::error_chain; use futures::future::join_all; use std::collections::HashMap; use std::fs; -// This has no foreign links because everything to do with config management should be isolated and generic -error_chain! { - errors { - /// For when the locale wasn't found. Locales will be checked for existence before fetching is attempted, so this indicates - /// a bug in the storage system. - NotFound(locale: String) { - description("translations not found") - display("translations for locale '{}' not found", locale) - } - /// For when translations couldn't be read for some generic reason. - ReadFailed(locale: String, err: String) { - description("translations couldn't be read") - display("translations for locale '{}' couldn't be read, error was '{}'", locale, err) - } - /// For when serializing into the `Translator` data structure failed. - SerializationFailed(locale: String, err: String) { - description("translations couldn't be serialized into translator") - display("translations for locale '{}' couldn't be serialized into translator, error was '{}'", locale, err) - } - } -} - /// A trait for systems that manage where to put translations. At simplest, we'll just write them to static files, but they might also /// be stored in a CMS. It is **strongly** advised that any implementations use some form of caching, guided by `FsTranslationsManager`. #[async_trait::async_trait] pub trait TranslationsManager: Clone { /// Gets a translator for the given locale. - async fn get_translator_for_locale(&self, locale: String) -> Result; + async fn get_translator_for_locale( + &self, + locale: String, + ) -> Result; /// Gets the translations in string format for the given locale (avoids deserialize-then-serialize). - async fn get_translations_str_for_locale(&self, locale: String) -> Result; + async fn get_translations_str_for_locale( + &self, + locale: String, + ) -> Result; } /// A utility function for allowing parallel futures execution. This returns a tuple of the locale and the translations as a JSON string. @@ -102,7 +106,10 @@ impl FsTranslationsManager { } #[async_trait::async_trait] impl TranslationsManager for FsTranslationsManager { - async fn get_translations_str_for_locale(&self, locale: String) -> Result { + async fn get_translations_str_for_locale( + &self, + locale: String, + ) -> Result { // Check if the locale is cached for // No dynamic caching, so if it isn't cached it stays that way if self.cached_locales.contains(&locale) { @@ -111,17 +118,31 @@ impl TranslationsManager for FsTranslationsManager { // The file must be named as the locale it describes let asset_path = format!("{}/{}.{}", self.root_path, locale, self.file_ext); let translations_str = match fs::metadata(&asset_path) { - Ok(_) => fs::read_to_string(&asset_path) - .map_err(|err| ErrorKind::ReadFailed(asset_path, err.to_string()))?, + Ok(_) => fs::read_to_string(&asset_path).map_err(|err| { + TranslationsManagerError::ReadFailed { + locale: locale.clone(), + source: err.into(), + } + })?, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - bail!(ErrorKind::NotFound(asset_path)) + return Err(TranslationsManagerError::NotFound { + locale: locale.clone(), + }) + } + Err(err) => { + return Err(TranslationsManagerError::ReadFailed { + locale: locale.clone(), + source: err.into(), + }) } - Err(err) => bail!(ErrorKind::ReadFailed(locale, err.to_string())), }; Ok(translations_str) } } - async fn get_translator_for_locale(&self, locale: String) -> Result { + async fn get_translator_for_locale( + &self, + locale: String, + ) -> Result { // Check if the locale is cached for // No dynamic caching, so if it isn't cached it stays that way let translations_str; @@ -131,8 +152,12 @@ impl TranslationsManager for FsTranslationsManager { translations_str = self.get_translations_str_for_locale(locale.clone()).await?; } // We expect the translations defined there, but not the locale itself - let translator = Translator::new(locale.clone(), translations_str) - .map_err(|err| ErrorKind::SerializationFailed(locale.clone(), err.to_string()))?; + let translator = Translator::new(locale.clone(), translations_str).map_err(|err| { + TranslationsManagerError::SerializationFailed { + locale: locale.clone(), + source: err.into(), + } + })?; Ok(translator) } @@ -150,12 +175,22 @@ impl DummyTranslationsManager { } #[async_trait::async_trait] impl TranslationsManager for DummyTranslationsManager { - async fn get_translations_str_for_locale(&self, _locale: String) -> Result { + async fn get_translations_str_for_locale( + &self, + _locale: String, + ) -> Result { Ok(String::new()) } - async fn get_translator_for_locale(&self, locale: String) -> Result { - let translator = Translator::new(locale.clone(), String::new()) - .map_err(|err| ErrorKind::SerializationFailed(locale.clone(), err.to_string()))?; + async fn get_translator_for_locale( + &self, + locale: String, + ) -> Result { + let translator = Translator::new(locale.clone(), String::new()).map_err(|err| { + TranslationsManagerError::SerializationFailed { + locale, + source: err.into(), + } + })?; Ok(translator) } diff --git a/packages/perseus/src/translator/errors.rs b/packages/perseus/src/translator/errors.rs index 79f66afe0f..ba38ceaa43 100644 --- a/packages/perseus/src/translator/errors.rs +++ b/packages/perseus/src/translator/errors.rs @@ -1,34 +1,33 @@ -pub use error_chain::bail; -use error_chain::error_chain; +#![allow(missing_docs)] -// This has its own error management because the user might be implementing translators themselves -error_chain! { - errors { - /// For when a translation ID doesn't exist. - TranslationIdNotFound(id: String, locale: String) { - description("translation id not found for current locale") - display("translation id '{}' not found for locale '{}'", id, locale) - } - /// For when the given string of translations couldn't be correctly parsed - TranslationsStrSerFailed(locale: String, err: String) { - description("given translations string couldn't be parsed") - display("given translations string for locale '{}' couldn't be parsed: '{}'", locale, err) - } - /// For when the given locale was invalid. This takes an error because different i18n systems may have different requirements. - InvalidLocale(locale: String, err: String) { - description("given locale was invalid") - display("given locale '{}' was invalid: '{}'", locale, err) - } - /// For when the translation of a message failed for some reason generally. - TranslationFailed(id: String, locale: String, err: String) { - description("message translation failed") - display("translation of message with id '{}' into locale '{}' failed: '{}'", id, locale, err) - } - /// For when the we couldn't arrive at a translation for some reason. This might be caused by an invalid variant for a compound - /// message. - NoTranslationDerived(id: String, locale: String) { - description("no translation derived for message") - display("no translation could be derived for message with id '{}' in locale '{}'", id, locale) - } - } +use thiserror::Error; + +/// Errors that can occur in a translator. +#[derive(Error, Debug)] +pub enum TranslatorError { + #[error("translation id '{id}' not found for locale '{locale}'")] + TranslationIdNotFound { id: String, locale: String }, + #[error("translations string for locale '{locale}' couldn't be parsed")] + TranslationsStrSerFailed { + locale: String, + // TODO + #[source] + source: Box, + }, + #[error("locale '{locale}' is of invalid form")] + InvalidLocale { + locale: String, + // We have a source here to support different i18n systems' definitions of a locale + #[source] + source: Box, + }, + #[error("translating message '{id}' into '{locale}' failed")] + TranslationFailed { + id: String, + locale: String, + source: Box, + }, + /// This could be caused by an invalid variant for a compound message. + #[error("no translation could be derived for message '{id}' in locale '{locale}'")] + NoTranslationDerived { id: String, locale: String }, } diff --git a/packages/perseus/src/translator/fluent.rs b/packages/perseus/src/translator/fluent.rs index 22365ce43f..01d67e2afa 100644 --- a/packages/perseus/src/translator/fluent.rs +++ b/packages/perseus/src/translator/fluent.rs @@ -20,25 +20,34 @@ pub struct FluentTranslator { } impl FluentTranslator { /// Creates a new translator for a given locale, passing in translations in FTL syntax form. - pub fn new(locale: String, ftl_string: String) -> Result { + pub fn new(locale: String, ftl_string: String) -> Result { let resource = FluentResource::try_new(ftl_string) // If this errors, we get it still and a vector of errors (wtf.) - .map_err(|(_, errs)| { - ErrorKind::TranslationsStrSerFailed( - locale.clone(), - errs.iter().map(|e| e.to_string()).collect(), - ) + .map_err(|(_, errs)| TranslatorError::TranslationsStrSerFailed { + locale: locale.clone(), + source: errs + .iter() + .map(|e| e.to_string()) + .collect::() + .into(), })?; let lang_id: LanguageIdentifier = locale.parse().map_err(|err: LanguageIdentifierError| { - ErrorKind::InvalidLocale(locale.clone(), err.to_string()) + TranslatorError::InvalidLocale { + locale: locale.clone(), + source: Box::new(err), + } })?; let mut bundle = FluentBundle::new(vec![lang_id]); bundle.add_resource(resource).map_err(|errs| { - ErrorKind::TranslationsStrSerFailed( - locale.clone(), - errs.iter().map(|e| e.to_string()).collect(), - ) + TranslatorError::TranslationsStrSerFailed { + locale: locale.clone(), + source: errs + .iter() + .map(|e| e.to_string()) + .collect::() + .into(), + } })?; Ok(Self { @@ -76,7 +85,7 @@ impl FluentTranslator { &self, id: I, args: Option, - ) -> Result { + ) -> Result { let id_str = id.to_string(); // Deal with the possibility of a specified variant let id_vec: Vec<&str> = id_str.split('.').collect(); @@ -88,10 +97,12 @@ impl FluentTranslator { let msg = self.bundle.get_message(&id_str); let msg = match msg { Some(msg) => msg, - None => bail!(ErrorKind::TranslationIdNotFound( - id_str, - self.locale.clone() - )), + None => { + return Err(TranslatorError::TranslationIdNotFound { + id: id_str, + locale: self.locale.clone(), + }) + } }; // This module accumulates errors in a provided buffer, we'll handle them later let mut errors = Vec::new(); @@ -118,26 +129,33 @@ impl FluentTranslator { } } } else { - bail!(ErrorKind::TranslationFailed( - id_str, - self.locale.clone(), - "no variant provided for compound message".to_string() - )) + return Err(TranslatorError::TranslationFailed { + id: id_str, + locale: self.locale.clone(), + source: "no variant provided for compound message".into(), + }); } } // Check for any errors // TODO apparently these aren't all fatal, but how do we know? if !errors.is_empty() { - bail!(ErrorKind::TranslationFailed( - id_str, - self.locale.clone(), - errors.iter().map(|e| e.to_string()).collect() - )) + return Err(TranslatorError::TranslationFailed { + id: id_str, + locale: self.locale.clone(), + source: errors + .iter() + .map(|e| e.to_string()) + .collect::() + .into(), + }); } // Make sure we've actually got a translation match translation { Some(translation) => Ok(translation.to_string()), - None => bail!(ErrorKind::NoTranslationDerived(id_str, self.locale.clone())), + None => Err(TranslatorError::NoTranslationDerived { + id: id_str, + locale: self.locale.clone(), + }), } } /// Gets the Fluent bundle for more advanced translation requirements. From 2daabb17e9b876881cf08394f0ee0f74901a5037 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 28 Sep 2021 07:48:46 +1000 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20migrated?= =?UTF-8?q?=20actix=20web=20integration=20to=20new=20error=20systems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/perseus-actix-web/Cargo.toml | 2 +- packages/perseus-actix-web/src/conv_req.rs | 4 +-- packages/perseus-actix-web/src/errors.rs | 34 +++++-------------- .../perseus-actix-web/src/initial_load.rs | 6 ++-- packages/perseus-actix-web/src/page_data.rs | 5 +-- .../perseus-actix-web/src/translations.rs | 3 +- 6 files changed, 21 insertions(+), 33 deletions(-) diff --git a/packages/perseus-actix-web/Cargo.toml b/packages/perseus-actix-web/Cargo.toml index a6ff201388..fc4987dc19 100644 --- a/packages/perseus-actix-web/Cargo.toml +++ b/packages/perseus-actix-web/Cargo.toml @@ -20,6 +20,6 @@ actix-files = "0.5" urlencoding = "2.1" serde = "1" serde_json = "1" -error-chain = "0.12" +thiserror = "1" futures = "0.3" sycamore = { version = "0.6", features = ["ssr"] } diff --git a/packages/perseus-actix-web/src/conv_req.rs b/packages/perseus-actix-web/src/conv_req.rs index 842097270c..24d4cfb0a4 100644 --- a/packages/perseus-actix-web/src/conv_req.rs +++ b/packages/perseus-actix-web/src/conv_req.rs @@ -2,7 +2,7 @@ use crate::errors::*; use perseus::{HttpRequest, Request}; /// Converts an Actix Web request into an `http::request`. -pub fn convert_req(raw: &actix_web::HttpRequest) -> Result { +pub fn convert_req(raw: &actix_web::HttpRequest) -> Result { let mut builder = HttpRequest::builder(); // Add headers one by one for (name, val) in raw.headers() { @@ -20,5 +20,5 @@ pub fn convert_req(raw: &actix_web::HttpRequest) -> Result { // We always use an empty body because, in a Perseus request, only the URI matters // Any custom data should therefore be sent in headers (if you're doing that, consider a dedicated API) .body(()) - .map_err(|err| ErrorKind::RequestConversionFailed(err.to_string()).into()) + .map_err(|err| Error::RequestConversionFailed { source: err }) } diff --git a/packages/perseus-actix-web/src/errors.rs b/packages/perseus-actix-web/src/errors.rs index a7742563d3..c8d545c8d3 100644 --- a/packages/perseus-actix-web/src/errors.rs +++ b/packages/perseus-actix-web/src/errors.rs @@ -1,29 +1,13 @@ #![allow(missing_docs)] -pub use error_chain::bail; -use error_chain::error_chain; +pub use perseus::errors::format_err; +use thiserror::Error; -// The `error_chain` setup for the whole crate -error_chain! { - // The custom errors for this crate (very broad) - errors { - /// - JsErr(err: String) { - description("an error occurred while interfacing with javascript") - display("the following error occurred while interfacing with javascript: {:?}", err) - } - /// For if converting an HTTP request from Actix Web format to Perseus format failed. - RequestConversionFailed(err: String) { - description("converting the request from actix-web format to perseus format failed") - display("converting the request from actix-web format to perseus format failed: {:?}", err) - } - } - links { - ConfigManager(::perseus::config_manager::Error, ::perseus::config_manager::ErrorKind); - } - // We work with many external libraries, all of which have their own errors - foreign_links { - Io(::std::io::Error); - Json(::serde_json::Error); - } +#[derive(Error, Debug)] +pub enum Error { + #[error("couldn't convert request from actix-web format to perseus format")] + RequestConversionFailed { + #[source] + source: actix_web::client::HttpError, + }, } diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs index d1780e6145..65d586a222 100644 --- a/packages/perseus-actix-web/src/initial_load.rs +++ b/packages/perseus-actix-web/src/initial_load.rs @@ -1,4 +1,5 @@ use crate::conv_req::convert_req; +use crate::errors::format_err; use crate::Options; use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; use perseus::error_pages::ErrorPageData; @@ -15,6 +16,7 @@ use std::rc::Rc; fn return_error_page( url: &str, status: &u16, + // This should already have been transformed into a string (with a source chain etc.) err: &str, translator: Option>, error_pages: &ErrorPages, @@ -102,7 +104,7 @@ pub async fn initial_load( Ok(http_req) => http_req, // If this fails, the client request is malformed, so it's a 400 Err(err) => { - return html_err(400, &err.to_string()); + return html_err(400, &format_err(&err)); } }; // Actually render the page as we would if this weren't an initial load @@ -119,7 +121,7 @@ pub async fn initial_load( Ok(page_data) => page_data, // We parse the error to return an appropriate status code Err(err) => { - return html_err(err_to_status_code(&err), &err.to_string()); + return html_err(err_to_status_code(&err), &format_err(&err)); } }; diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs index f05eb52642..39764b29a1 100644 --- a/packages/perseus-actix-web/src/page_data.rs +++ b/packages/perseus-actix-web/src/page_data.rs @@ -1,4 +1,5 @@ use crate::conv_req::convert_req; +use crate::errors::format_err; use crate::Options; use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; use perseus::{ @@ -32,7 +33,7 @@ pub async fn page_data( // If this fails, the client request is malformed, so it's a 400 Err(err) => { return HttpResponse::build(StatusCode::from_u16(400).unwrap()) - .body(err.to_string()) + .body(format_err(&err)) } }; // Get the template to use @@ -67,7 +68,7 @@ pub async fn page_data( // We parse the error to return an appropriate status code Err(err) => { HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap()) - .body(err.to_string()) + .body(format_err(&err)) } } } else { diff --git a/packages/perseus-actix-web/src/translations.rs b/packages/perseus-actix-web/src/translations.rs index 06d0341da9..93322c5e13 100644 --- a/packages/perseus-actix-web/src/translations.rs +++ b/packages/perseus-actix-web/src/translations.rs @@ -1,3 +1,4 @@ +use crate::errors::format_err; use crate::Options; use actix_web::{web, HttpRequest, HttpResponse}; use perseus::TranslationsManager; @@ -18,7 +19,7 @@ pub async fn translations( .await; let translations = match translations { Ok(translations) => translations, - Err(err) => return HttpResponse::InternalServerError().body(err.to_string()), + Err(err) => return HttpResponse::InternalServerError().body(format_err(&err)), }; HttpResponse::Ok().body(translations) From 5340d16a895797c3dc4ebcb3f181e1b6b0bba326 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 28 Sep 2021 11:39:56 +1000 Subject: [PATCH 04/10] =?UTF-8?q?fix:=20=F0=9F=90=9B=20used=20absolute=20p?= =?UTF-8?q?ath=20for=20`Rc`=20in=20one=20variant=20of=20`t!`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/perseus/src/translator/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perseus/src/translator/mod.rs b/packages/perseus/src/translator/mod.rs index df88821e11..235bb3db73 100644 --- a/packages/perseus/src/translator/mod.rs +++ b/packages/perseus/src/translator/mod.rs @@ -19,7 +19,7 @@ macro_rules! t { // When there are no arguments to interpolate ($id:expr) => { { - let translator = ::sycamore::context::use_context::>(); + let translator = ::sycamore::context::use_context::<::std::rc::Rc<::perseus::Translator>>(); translator.translate($id, ::std::option::Option::None) } }; From e8ca3f45d500533c4ebd2566625f651f61ce53f2 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 28 Sep 2021 11:54:45 +1000 Subject: [PATCH 05/10] =?UTF-8?q?feat(templates):=20=E2=9C=A8=20added=20`i?= =?UTF-8?q?s=5Fserver!`=20macro=20to=20check=20if=20running=20on=20server?= =?UTF-8?q?=20or=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/next/src/templates/intro.md | 10 ++++++- examples/i18n/src/templates/about.rs | 11 +++++++- packages/perseus/src/template.rs | 37 ++++++++++++++++++++------ packages/perseus/src/translator/mod.rs | 9 ++++--- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/docs/next/src/templates/intro.md b/docs/next/src/templates/intro.md index 748b100e37..4eec4aad8c 100644 --- a/docs/next/src/templates/intro.md +++ b/docs/next/src/templates/intro.md @@ -50,4 +50,12 @@ There is one use-case though that requires a bit more fiddling: having a differe ## Checking Render Context -> This feature is currently in development, the tracking issue is available [here](https://github.com/arctic-hen7/perseus/issues/26). +It's often necessary to make sure you're only running some logic on the client-side, particularly anything to do with `web_sys`, which will `panic!` if used on the server. Because Perseus renders your templates in both environments, you'll need to explicitly check if you want to do something only on the client (like get an authentication token from a cookie). This can be done trivially with the `is_server!` macro, which does exactly what it says on the tin. Here's an example from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/i18n/src/templates/about.rs): + +```rust,no_run,no_playground +{{#include ../../../../examples/i18n/src/templates/about.rs}} +``` + +This is a very contrived example, but what you should note if you try this is the flash from `server` to `client` (when you go to the page from the URL bar, not when you go in from the link on the index page), because the page is pre-rendered on the server and then hydrated on the client. This is an important principle of Perseus, and you should be aware of this potential flashing (easily solved by a less contrived example) when your users [initially load](../advanced/initial-loads.md) a page. + +One important thing to note with this macro is that it will only work in a _reactive scope_ because it uses Sycamore's [context system](https://sycamore-rs.netlify.app/docs/advanced/contexts). In other words, you can only use it inside a `template!`, `create_effect`, or the like. diff --git a/examples/i18n/src/templates/about.rs b/examples/i18n/src/templates/about.rs index 8c9085c638..d86a1037ee 100644 --- a/examples/i18n/src/templates/about.rs +++ b/examples/i18n/src/templates/about.rs @@ -1,4 +1,4 @@ -use perseus::{t, Template}; +use perseus::{is_server, t, Template}; use std::rc::Rc; use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; @@ -6,6 +6,15 @@ use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTe pub fn about_page() -> SycamoreTemplate { template! { p { (t!("about")) } + p { + ( + if is_server!() { + "This is running on the server." + } else { + "This is running on the client." + } + ) + } } } diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index d2bb1c56d2..508d5b0cba 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -13,12 +13,16 @@ use std::rc::Rc; use sycamore::context::{ContextProvider, ContextProviderProps}; use sycamore::prelude::{template, GenericNode, Template as SycamoreTemplate}; -/// Used to encapsulate whether or not a template is running on the client or server. We use a `struct` so as not to interfere with -/// any user-set context. -#[derive(Clone, Debug)] +/// This encapsulates all elements of context currently provided to Perseus templates. While this can be used manually, there are macros +/// to make this easier for each thing in here. +#[derive(Clone)] pub struct RenderCtx { - /// Whether or not we're being executed on the server-side. + /// Whether or not we're being executed on the server-side. This can be used to gate `web_sys` functions and the like that expect + /// to be run in the browser. pub is_server: bool, + /// A translator for templates to use. This will still be present in non-i18n apps, but it will have no message IDs and support for + /// the non-existent locale `xx-XX`. + pub translator: Rc, } /// Represents all the different states that can be generated for a single template, allowing amalgamation logic to be run with the knowledge @@ -218,13 +222,15 @@ impl Template { &self, props: Option, translator: Rc, - _is_server: bool, + is_server: bool, ) -> SycamoreTemplate { template! { - // TODO tell templates where they're being rendered // We provide the translator through context, which avoids having to define a separate variable for every translation due to Sycamore's `template!` macro taking ownership with `move` closures ContextProvider(ContextProviderProps { - value: Rc::clone(&translator), + value: RenderCtx { + is_server, + translator: Rc::clone(&translator) + }, children: || (self.template)(props) }) } @@ -236,7 +242,12 @@ impl Template { template! { // We provide the translator through context, which avoids having to define a separate variable for every translation due to Sycamore's `template!` macro taking ownership with `move` closures ContextProvider(ContextProviderProps { - value: Rc::clone(&translator), + value: RenderCtx { + // This function renders to a string, so we're effectively always on the server + // It's also only ever run on the server + is_server: true, + translator: Rc::clone(&translator) + }, children: || (self.head)(props) }) } @@ -494,3 +505,13 @@ macro_rules! get_templates_map { /// A type alias for a `HashMap` of `Template`s. pub type TemplateMap = HashMap>; + +/// Checks if we're on the server or the client. This must be run inside a reactive scope (e.g. a `template!` or `create_effect`), +/// because it uses Sycamore context. +#[macro_export] +macro_rules! is_server { + () => {{ + let render_ctx = ::sycamore::context::use_context::<::perseus::template::RenderCtx>(); + render_ctx.is_server + }}; +} diff --git a/packages/perseus/src/translator/mod.rs b/packages/perseus/src/translator/mod.rs index 235bb3db73..c9be0003d5 100644 --- a/packages/perseus/src/translator/mod.rs +++ b/packages/perseus/src/translator/mod.rs @@ -19,7 +19,8 @@ macro_rules! t { // When there are no arguments to interpolate ($id:expr) => { { - let translator = ::sycamore::context::use_context::<::std::rc::Rc<::perseus::Translator>>(); + let render_ctx = ::sycamore::context::use_context::<::perseus::template::RenderCtx>(); + let translator = render_ctx.translator; translator.translate($id, ::std::option::Option::None) } }; @@ -28,7 +29,8 @@ macro_rules! t { $($key:literal: $value:expr),+ }) => { { - let translator = ::sycamore::context::use_context::<::std::rc::Rc<::perseus::Translator>>(); + let render_ctx = ::sycamore::context::use_context::<::perseus::template::RenderCtx>(); + let translator = render_ctx.translator; let mut args = ::fluent_bundle::FluentArgs::new(); $( args.set($key, $value); @@ -43,7 +45,8 @@ macro_rules! t { #[macro_export] macro_rules! link { ($url:expr) => {{ - let translator = ::sycamore::context::use_context::<::std::rc::Rc<::perseus::Translator>>(); + let render_ctx = ::sycamore::context::use_context::<::perseus::template::RenderCtx>(); + let translator = render_ctx.translator; translator.url($url) }}; } From ca04f12c0ba46827eda87c5d410ead4fcb8f8984 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 28 Sep 2021 12:21:59 +1000 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=F0=9F=90=9B=20fixed=20error=20`#[?= =?UTF-8?q?from]`=20for=20`ClientError`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was `FetchError`, should be `ClientError`. --- packages/perseus/src/errors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index 1fafd92d7a..d6a902d067 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -8,7 +8,7 @@ use thiserror::Error; #[derive(Error, Debug)] pub enum Error { #[error(transparent)] - ClientError(#[from] FetchError), + ClientError(#[from] ClientError), #[error(transparent)] ServerError(#[from] ServerError), } From 033fca82e588466e6a974fb7648d40d24529841f Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 28 Sep 2021 14:52:04 +1000 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=E2=9C=A8=20made=20error=20format?= =?UTF-8?q?ting=20use=20`fmterr`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We don't need `anyhow` anymore. --- packages/perseus-actix-web/Cargo.toml | 1 + packages/perseus-actix-web/src/errors.rs | 2 -- packages/perseus-actix-web/src/initial_load.rs | 6 +++--- packages/perseus-actix-web/src/page_data.rs | 7 +++---- packages/perseus-actix-web/src/translations.rs | 4 ++-- packages/perseus-macro/src/lib.rs | 2 +- packages/perseus/Cargo.toml | 1 + packages/perseus/src/errors.rs | 6 ------ packages/perseus/src/shell.rs | 16 ++++++++-------- 9 files changed, 19 insertions(+), 26 deletions(-) diff --git a/packages/perseus-actix-web/Cargo.toml b/packages/perseus-actix-web/Cargo.toml index fc4987dc19..90f14de369 100644 --- a/packages/perseus-actix-web/Cargo.toml +++ b/packages/perseus-actix-web/Cargo.toml @@ -21,5 +21,6 @@ urlencoding = "2.1" serde = "1" serde_json = "1" thiserror = "1" +fmterr = "0.1" futures = "0.3" sycamore = { version = "0.6", features = ["ssr"] } diff --git a/packages/perseus-actix-web/src/errors.rs b/packages/perseus-actix-web/src/errors.rs index c8d545c8d3..3a32108c20 100644 --- a/packages/perseus-actix-web/src/errors.rs +++ b/packages/perseus-actix-web/src/errors.rs @@ -1,6 +1,4 @@ #![allow(missing_docs)] - -pub use perseus::errors::format_err; use thiserror::Error; #[derive(Error, Debug)] diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs index 65d586a222..62bc83ae19 100644 --- a/packages/perseus-actix-web/src/initial_load.rs +++ b/packages/perseus-actix-web/src/initial_load.rs @@ -1,7 +1,7 @@ use crate::conv_req::convert_req; -use crate::errors::format_err; use crate::Options; use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; +use fmterr::fmt_err; use perseus::error_pages::ErrorPageData; use perseus::html_shell::interpolate_page_data; use perseus::router::{match_route, RouteInfo, RouteVerdict}; @@ -104,7 +104,7 @@ pub async fn initial_load( Ok(http_req) => http_req, // If this fails, the client request is malformed, so it's a 400 Err(err) => { - return html_err(400, &format_err(&err)); + return html_err(400, &fmt_err(&err)); } }; // Actually render the page as we would if this weren't an initial load @@ -121,7 +121,7 @@ pub async fn initial_load( Ok(page_data) => page_data, // We parse the error to return an appropriate status code Err(err) => { - return html_err(err_to_status_code(&err), &format_err(&err)); + return html_err(err_to_status_code(&err), &fmt_err(&err)); } }; diff --git a/packages/perseus-actix-web/src/page_data.rs b/packages/perseus-actix-web/src/page_data.rs index 39764b29a1..a94d7e1540 100644 --- a/packages/perseus-actix-web/src/page_data.rs +++ b/packages/perseus-actix-web/src/page_data.rs @@ -1,7 +1,7 @@ use crate::conv_req::convert_req; -use crate::errors::format_err; use crate::Options; use actix_web::{http::StatusCode, web, HttpRequest, HttpResponse}; +use fmterr::fmt_err; use perseus::{ err_to_status_code, serve::get_page_for_template, ConfigManager, TranslationsManager, }; @@ -32,8 +32,7 @@ pub async fn page_data( Ok(http_req) => http_req, // If this fails, the client request is malformed, so it's a 400 Err(err) => { - return HttpResponse::build(StatusCode::from_u16(400).unwrap()) - .body(format_err(&err)) + return HttpResponse::build(StatusCode::from_u16(400).unwrap()).body(fmt_err(&err)) } }; // Get the template to use @@ -68,7 +67,7 @@ pub async fn page_data( // We parse the error to return an appropriate status code Err(err) => { HttpResponse::build(StatusCode::from_u16(err_to_status_code(&err)).unwrap()) - .body(format_err(&err)) + .body(fmt_err(&err)) } } } else { diff --git a/packages/perseus-actix-web/src/translations.rs b/packages/perseus-actix-web/src/translations.rs index 93322c5e13..ef64122e8e 100644 --- a/packages/perseus-actix-web/src/translations.rs +++ b/packages/perseus-actix-web/src/translations.rs @@ -1,6 +1,6 @@ -use crate::errors::format_err; use crate::Options; use actix_web::{web, HttpRequest, HttpResponse}; +use fmterr::fmt_err; use perseus::TranslationsManager; /// The handler for calls to `.perseus/translations/{locale}`. This will manage returning errors and the like. THe JSON body returned @@ -19,7 +19,7 @@ pub async fn translations( .await; let translations = match translations { Ok(translations) => translations, - Err(err) => return HttpResponse::InternalServerError().body(format_err(&err)), + Err(err) => return HttpResponse::InternalServerError().body(fmt_err(&err)), }; HttpResponse::Ok().body(translations) diff --git a/packages/perseus-macro/src/lib.rs b/packages/perseus-macro/src/lib.rs index c4c058a0e2..ed29dcb400 100644 --- a/packages/perseus-macro/src/lib.rs +++ b/packages/perseus-macro/src/lib.rs @@ -31,7 +31,7 @@ mod test; use darling::FromMeta; use proc_macro::TokenStream; -/// Marks the given function as a Perseus tests. Functions marked with this attribute must have the following signature: +/// Marks the given function as a Perseus test. Functions marked with this attribute must have the following signature: /// `async fn foo(client: &mut fantoccini::Client) -> Result<>`. #[proc_macro_attribute] pub fn test(args: TokenStream, input: TokenStream) -> TokenStream { diff --git a/packages/perseus/Cargo.toml b/packages/perseus/Cargo.toml index a3eadf589a..5dacb22943 100644 --- a/packages/perseus/Cargo.toml +++ b/packages/perseus/Cargo.toml @@ -24,6 +24,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" typetag = "0.1" thiserror = "1" +fmterr = "0.1" futures = "0.3" console_error_panic_hook = "0.1.6" urlencoding = "2.1" diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index d6a902d067..31fc2ff8b4 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -139,9 +139,3 @@ pub enum ErrorCause { Client(Option), Server(Option), } - -/// Formats an error to be displayed to a user. This will include a series of indented sources. -// TODO add a source chain etc. -pub fn format_err(err: &impl std::error::Error) -> String { - err.to_string() -} diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index c1d4202809..96783ab7b0 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -1,10 +1,10 @@ use crate::error_pages::ErrorPageData; -use crate::errors::format_err; use crate::errors::*; use crate::serve::PageData; use crate::template::Template; use crate::ClientTranslationsManager; use crate::ErrorPages; +use fmterr::fmt_err; use js_sys::Reflect; use std::cell::RefCell; use std::collections::HashMap; @@ -253,9 +253,9 @@ pub async fn app_shell( container_rx_elem.set_inner_html(""); match &err { // These errors happen because we couldn't get a translator, so they certainly don't get one - ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.render_page(url, status, &format_err(&err), None, &container_rx_elem), - ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.render_page(url, &500, &format_err(&err), None, &container_rx_elem), - ClientError::LocaleNotSupported { .. } => return error_pages.render_page(&format!("/{}/...", locale), &404, &format_err(&err), None, &container_rx_elem), + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.render_page(url, status, &fmt_err(&err), None, &container_rx_elem), + ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.render_page(url, &500, &fmt_err(&err), None, &container_rx_elem), + ClientError::LocaleNotSupported { .. } => return error_pages.render_page(&format!("/{}/...", locale), &404, &fmt_err(&err), None, &container_rx_elem), // No other errors should be returned _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") } @@ -332,9 +332,9 @@ pub async fn app_shell( Ok(translator) => translator, Err(err) => match &err { // These errors happen because we couldn't get a translator, so they certainly don't get one - ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.render_page(url, status, &format_err(&err), None, &container_rx_elem), - ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.render_page(url, &500, &format_err(&err), None, &container_rx_elem), - ClientError::LocaleNotSupported { locale } => return error_pages.render_page(&format!("/{}/...", locale), &404, &format_err(&err), None, &container_rx_elem), + ClientError::FetchError(FetchError::NotOk { url, status, .. }) => return error_pages.render_page(url, status, &fmt_err(&err), None, &container_rx_elem), + ClientError::FetchError(FetchError::SerFailed { url, .. }) => return error_pages.render_page(url, &500, &fmt_err(&err), None, &container_rx_elem), + ClientError::LocaleNotSupported { locale } => return error_pages.render_page(&format!("/{}/...", locale), &404, &fmt_err(&err), None, &container_rx_elem), // No other errors should be returned _ => panic!("expected 'AssetNotOk'/'AssetSerFailed'/'LocaleNotSupported' error, found other unacceptable error") } @@ -371,7 +371,7 @@ pub async fn app_shell( Err(err) => match &err { // No translators ready yet ClientError::FetchError(FetchError::NotOk { url, status, .. }) => error_pages - .render_page(url, status, &format_err(&err), None, &container_rx_elem), + .render_page(url, status, &fmt_err(&err), None, &container_rx_elem), // No other errors should be returned _ => panic!("expected 'AssetNotOk' error, found other unacceptable error"), }, From 19e685bb44a0d1fa50749b8aa0c3ee7d78816cab Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 28 Sep 2021 16:26:16 +1000 Subject: [PATCH 08/10] =?UTF-8?q?feat(templates):=20=E2=9C=A8=20allowed=20?= =?UTF-8?q?usage=20of=20any=20error=20type=20in=20render=20functions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before, only `String` was allowed as an error type. This also makes `?` usable. BREAKING CHANGE: `StringResult`/`StringResultWithCause` are replaced by `RenderFnResult`/`RenderFnResultWithCause` --- examples/basic/src/templates/index.rs | 7 ++- examples/i18n/src/templates/post.rs | 9 ++-- examples/showcase/src/error_pages.rs | 3 +- .../showcase/src/templates/amalgamation.rs | 42 ++++++++--------- examples/showcase/src/templates/index.rs | 7 ++- examples/showcase/src/templates/ip.rs | 12 ++--- examples/showcase/src/templates/post.rs | 16 ++++--- examples/showcase/src/templates/time.rs | 16 ++++--- examples/showcase/src/templates/time_root.rs | 7 ++- packages/perseus/src/errors.rs | 20 +++++++++ packages/perseus/src/lib.rs | 4 +- packages/perseus/src/template.rs | 45 ++++++++++--------- 12 files changed, 108 insertions(+), 80 deletions(-) diff --git a/examples/basic/src/templates/index.rs b/examples/basic/src/templates/index.rs index d26cc1753b..997d8711f4 100644 --- a/examples/basic/src/templates/index.rs +++ b/examples/basic/src/templates/index.rs @@ -1,6 +1,6 @@ use perseus::{ http::header::{HeaderMap, HeaderName}, - GenericNode, StringResultWithCause, Template, + GenericNode, RenderFnResultWithCause, Template, }; use serde::{Deserialize, Serialize}; use std::rc::Rc; @@ -27,11 +27,10 @@ pub fn get_template() -> Template { .set_headers_fn(set_headers_fn()) } -pub async fn get_build_props(_path: String) -> StringResultWithCause { +pub async fn get_build_props(_path: String) -> RenderFnResultWithCause { Ok(serde_json::to_string(&IndexPageProps { greeting: "Hello World!".to_string(), - }) - .unwrap()) + })?) // This `?` declares the default, that the server is the cause of the error } pub fn template_fn() -> perseus::template::TemplateFn { diff --git a/examples/i18n/src/templates/post.rs b/examples/i18n/src/templates/post.rs index 24049b963e..2561475194 100644 --- a/examples/i18n/src/templates/post.rs +++ b/examples/i18n/src/templates/post.rs @@ -1,4 +1,4 @@ -use perseus::{StringResultWithCause, Template}; +use perseus::{RenderFnResult, RenderFnResultWithCause, Template}; use serde::{Deserialize, Serialize}; use std::rc::Rc; use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; @@ -30,7 +30,7 @@ pub fn get_template() -> Template { .template(template_fn()) } -pub async fn get_static_props(path: String) -> StringResultWithCause { +pub async fn get_static_props(path: String) -> RenderFnResultWithCause { // This is just an example let title = urlencoding::decode(&path).unwrap(); let content = format!( @@ -41,11 +41,10 @@ pub async fn get_static_props(path: String) -> StringResultWithCause { Ok(serde_json::to_string(&PostPageProps { title: title.to_string(), content, - }) - .unwrap()) + })?) // This `?` declares the default, that the server is the cause of the error } -pub async fn get_static_paths() -> Result, String> { +pub async fn get_static_paths() -> RenderFnResult> { Ok(vec!["test".to_string(), "blah/test/blah".to_string()]) } diff --git a/examples/showcase/src/error_pages.rs b/examples/showcase/src/error_pages.rs index a1396cdcec..2db2486715 100644 --- a/examples/showcase/src/error_pages.rs +++ b/examples/showcase/src/error_pages.rs @@ -10,9 +10,10 @@ pub fn get_error_pages() -> ErrorPages { })); error_pages.add_page( 404, - Rc::new(|_, _, _, _| { + Rc::new(|_, _, err, _| { template! { p { "Page not found." } + p { (err) } } }), ); diff --git a/examples/showcase/src/templates/amalgamation.rs b/examples/showcase/src/templates/amalgamation.rs index d0f90d41d4..60e92d0404 100644 --- a/examples/showcase/src/templates/amalgamation.rs +++ b/examples/showcase/src/templates/amalgamation.rs @@ -1,4 +1,4 @@ -use perseus::{Request, States, StringResultWithCause, Template}; +use perseus::{RenderFnResultWithCause, Request, States, Template}; use serde::{Deserialize, Serialize}; use std::rc::Rc; use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; @@ -23,37 +23,33 @@ pub fn get_template() -> Template { .template(template_fn()) } -pub fn amalgamate_states(states: States) -> StringResultWithCause> { +pub fn amalgamate_states(states: States) -> RenderFnResultWithCause> { // We know they'll both be defined - let build_state = - serde_json::from_str::(&states.build_state.unwrap()).unwrap(); - let req_state = - serde_json::from_str::(&states.request_state.unwrap()).unwrap(); - - Ok(Some( - serde_json::to_string(&AmalagamationPageProps { - message: format!( - "Hello from the amalgamation! (Build says: '{}', server says: '{}'.)", - build_state.message, req_state.message - ), - }) - .unwrap(), - )) + let build_state = serde_json::from_str::(&states.build_state.unwrap())?; + let req_state = serde_json::from_str::(&states.request_state.unwrap())?; + + Ok(Some(serde_json::to_string(&AmalagamationPageProps { + message: format!( + "Hello from the amalgamation! (Build says: '{}', server says: '{}'.)", + build_state.message, req_state.message + ), + })?)) } -pub async fn get_build_state(_path: String) -> StringResultWithCause { +pub async fn get_build_state(_path: String) -> RenderFnResultWithCause { Ok(serde_json::to_string(&AmalagamationPageProps { message: "Hello from the build process!".to_string(), - }) - .unwrap()) + })?) } -pub async fn get_request_state(_path: String, _req: Request) -> StringResultWithCause { - // Err(("this is a test error!".to_string(), perseus::ErrorCause::Client(None))) +pub async fn get_request_state(_path: String, _req: Request) -> RenderFnResultWithCause { + // Err(perseus::GenericErrorWithCause { + // error: "this is a test error!".into(), + // cause: perseus::ErrorCause::Client(None) + // }) Ok(serde_json::to_string(&AmalagamationPageProps { message: "Hello from the server!".to_string(), - }) - .unwrap()) + })?) } pub fn template_fn() -> perseus::template::TemplateFn { diff --git a/examples/showcase/src/templates/index.rs b/examples/showcase/src/templates/index.rs index 63b217edfc..53b8f40e15 100644 --- a/examples/showcase/src/templates/index.rs +++ b/examples/showcase/src/templates/index.rs @@ -1,4 +1,4 @@ -use perseus::{StringResultWithCause, Template}; +use perseus::{RenderFnResultWithCause, Template}; use serde::{Deserialize, Serialize}; use std::rc::Rc; use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; @@ -21,11 +21,10 @@ pub fn get_template() -> Template { .template(template_fn()) } -pub async fn get_static_props(_path: String) -> StringResultWithCause { +pub async fn get_static_props(_path: String) -> RenderFnResultWithCause { Ok(serde_json::to_string(&IndexPageProps { greeting: "Hello World!".to_string(), - }) - .unwrap()) + })?) } pub fn template_fn() -> perseus::template::TemplateFn { diff --git a/examples/showcase/src/templates/ip.rs b/examples/showcase/src/templates/ip.rs index a046ab13a0..0457473788 100644 --- a/examples/showcase/src/templates/ip.rs +++ b/examples/showcase/src/templates/ip.rs @@ -1,4 +1,4 @@ -use perseus::{Request, StringResultWithCause, Template}; +use perseus::{RenderFnResultWithCause, Request, Template}; use serde::{Deserialize, Serialize}; use std::rc::Rc; use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; @@ -25,8 +25,11 @@ pub fn get_template() -> Template { .template(template_fn()) } -pub async fn get_request_state(_path: String, req: Request) -> StringResultWithCause { - // Err(("this is a test error!".to_string(), perseus::ErrorCause::Client(None))) +pub async fn get_request_state(_path: String, req: Request) -> RenderFnResultWithCause { + // Err(perseus::GenericErrorWithCause { + // error: "this is a test error!".into(), + // cause: perseus::ErrorCause::Client(None) + // }) Ok(serde_json::to_string(&IpPageProps { // Gets the client's IP address ip: format!( @@ -35,8 +38,7 @@ pub async fn get_request_state(_path: String, req: Request) -> StringResultWithC .get("X-Forwarded-For") .unwrap_or(&perseus::http::HeaderValue::from_str("hidden from view!").unwrap()) ), - }) - .unwrap()) + })?) } pub fn template_fn() -> perseus::template::TemplateFn { diff --git a/examples/showcase/src/templates/post.rs b/examples/showcase/src/templates/post.rs index 9bb798ef04..a0bcd7900d 100644 --- a/examples/showcase/src/templates/post.rs +++ b/examples/showcase/src/templates/post.rs @@ -1,4 +1,6 @@ -use perseus::{ErrorCause, StringResultWithCause, Template}; +use perseus::{ + ErrorCause, GenericErrorWithCause, RenderFnResult, RenderFnResultWithCause, Template, +}; use serde::{Deserialize, Serialize}; use std::rc::Rc; use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; @@ -31,10 +33,13 @@ pub fn get_template() -> Template { .template(template_fn()) } -pub async fn get_static_props(path: String) -> StringResultWithCause { +pub async fn get_static_props(path: String) -> RenderFnResultWithCause { // This path is illegal, and can't be rendered if path == "post/tests" { - return Err(("illegal page".to_string(), ErrorCause::Client(Some(404)))); + return Err(GenericErrorWithCause { + error: "illegal page".into(), + cause: ErrorCause::Client(Some(404)), + }); } // This is just an example let title = urlencoding::decode(&path).unwrap(); @@ -46,11 +51,10 @@ pub async fn get_static_props(path: String) -> StringResultWithCause { Ok(serde_json::to_string(&PostPageProps { title: title.to_string(), content, - }) - .unwrap()) + })?) } -pub async fn get_static_paths() -> Result, String> { +pub async fn get_static_paths() -> RenderFnResult> { Ok(vec!["test".to_string(), "blah/test/blah".to_string()]) } diff --git a/examples/showcase/src/templates/time.rs b/examples/showcase/src/templates/time.rs index b65fe37ce2..a348b31ba3 100644 --- a/examples/showcase/src/templates/time.rs +++ b/examples/showcase/src/templates/time.rs @@ -1,4 +1,6 @@ -use perseus::{ErrorCause, StringResultWithCause, Template}; +use perseus::{ + ErrorCause, GenericErrorWithCause, RenderFnResult, RenderFnResultWithCause, Template, +}; use serde::{Deserialize, Serialize}; use std::rc::Rc; use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; @@ -25,18 +27,20 @@ pub fn get_template() -> Template { .build_paths_fn(Rc::new(get_build_paths)) } -pub async fn get_build_state(path: String) -> StringResultWithCause { +pub async fn get_build_state(path: String) -> RenderFnResultWithCause { // This path is illegal, and can't be rendered if path == "timeisr/tests" { - return Err(("illegal page".to_string(), ErrorCause::Client(Some(404)))); + return Err(GenericErrorWithCause { + error: "illegal page".into(), + cause: ErrorCause::Client(Some(404)), + }); } Ok(serde_json::to_string(&TimePageProps { time: format!("{:?}", std::time::SystemTime::now()), - }) - .unwrap()) + })?) } -pub async fn get_build_paths() -> Result, String> { +pub async fn get_build_paths() -> RenderFnResult> { Ok(vec!["test".to_string()]) } diff --git a/examples/showcase/src/templates/time_root.rs b/examples/showcase/src/templates/time_root.rs index e606c9c3a3..2cffea0d98 100644 --- a/examples/showcase/src/templates/time_root.rs +++ b/examples/showcase/src/templates/time_root.rs @@ -1,4 +1,4 @@ -use perseus::{StringResultWithCause, Template}; +use perseus::{RenderFnResultWithCause, Template}; use serde::{Deserialize, Serialize}; use std::rc::Rc; use sycamore::prelude::{component, template, GenericNode, Template as SycamoreTemplate}; @@ -25,11 +25,10 @@ pub fn get_template() -> Template { .build_state_fn(Rc::new(get_build_state)) } -pub async fn get_build_state(_path: String) -> StringResultWithCause { +pub async fn get_build_state(_path: String) -> RenderFnResultWithCause { Ok(serde_json::to_string(&TimePageProps { time: format!("{:?}", std::time::SystemTime::now()), - }) - .unwrap()) + })?) } pub fn template_fn() -> perseus::template::TemplateFn { diff --git a/packages/perseus/src/errors.rs b/packages/perseus/src/errors.rs index 31fc2ff8b4..37f133bb5c 100644 --- a/packages/perseus/src/errors.rs +++ b/packages/perseus/src/errors.rs @@ -139,3 +139,23 @@ pub enum ErrorCause { Client(Option), Server(Option), } + +/// An error that has an attached cause that blames either the client or the server for its occurrence. You can convert any error +/// into this with `.into()` or `?`, which will set the cause to the server by default, resulting in a *500 Internal Server Error* +/// HTTP status code. If this isn't what you want, you'll need to initialize this explicitly. +#[derive(Debug)] +pub struct GenericErrorWithCause { + /// The underlying error. + pub error: Box, + /// The cause of the error. + pub cause: ErrorCause, +} +// We should be able to convert any error into this easily (e.g. with `?`) with the default being to blame the server +impl From for GenericErrorWithCause { + fn from(error: E) -> Self { + Self { + error: error.into(), + cause: ErrorCause::Server(None), + } + } +} diff --git a/packages/perseus/src/lib.rs b/packages/perseus/src/lib.rs index b1edf4d6b8..7a5f239515 100644 --- a/packages/perseus/src/lib.rs +++ b/packages/perseus/src/lib.rs @@ -78,13 +78,13 @@ pub use crate::build::{build_app, build_template, build_templates_for_locale}; pub use crate::client_translations_manager::ClientTranslationsManager; pub use crate::config_manager::{ConfigManager, FsConfigManager}; pub use crate::error_pages::ErrorPages; -pub use crate::errors::{err_to_status_code, ErrorCause}; +pub use crate::errors::{err_to_status_code, ErrorCause, GenericErrorWithCause}; pub use crate::export::export_app; pub use crate::locale_detector::detect_locale; pub use crate::locales::Locales; pub use crate::serve::{get_page, get_render_cfg}; pub use crate::shell::app_shell; -pub use crate::template::{States, StringResult, StringResultWithCause, Template, TemplateMap}; +pub use crate::template::{RenderFnResult, RenderFnResultWithCause, States, Template, TemplateMap}; pub use crate::translations_manager::{FsTranslationsManager, TranslationsManager}; pub use crate::translator::{Translator, TRANSLATOR_FILE_EXT}; diff --git a/packages/perseus/src/template.rs b/packages/perseus/src/template.rs index 508d5b0cba..6bee1edb09 100644 --- a/packages/perseus/src/template.rs +++ b/packages/perseus/src/template.rs @@ -59,12 +59,17 @@ impl States { } } } - -/// A generic error type that mandates a string error. This sidesteps horrible generics while maintaining DX. -pub type StringResult = std::result::Result; -/// A generic error type that mandates a string errorr and a statement of causation (client or server) for status code generation. -// TODO let the user use any custom error type -pub type StringResultWithCause = std::result::Result; +/// A generic error type that can be adapted for any errors the user may want to return from a render function. `.into()` can be used +/// to convert most error types into this without further hassle. Otherwise, use `Box::new()` on the type. +pub type RenderFnResult = std::result::Result>; +/// A generic error type that can be adapted for any errors the user may want to return from a render function, as with `RenderFnResult`. +/// However, this also includes a mandatory statement of causation for any errors, which assigns blame for them to either the client +/// or the server. In cases where this is ambiguous, this allows returning accurate HTTP status codes. +/// +/// Note that you can automatically convert from your error type into this with `.into()` or `?`, which will blame the server for the +/// error by default and return a *500 Internal Server Error* HTTP status code. Otherwise, you'll need to manually instantiate `ErrorWithCause` +/// and return that as the error type. +pub type RenderFnResultWithCause = std::result::Result; /// A generic return type for asynchronous functions that we need to store in a struct. type AsyncFnReturn = Pin>>; @@ -110,20 +115,20 @@ macro_rules! make_async_trait { } // A series of asynchronous closure traits that prevent the user from having to pin their functions -make_async_trait!(GetBuildPathsFnType, StringResult>); +make_async_trait!(GetBuildPathsFnType, RenderFnResult>); // The build state strategy needs an error cause if it's invoked from incremental make_async_trait!( GetBuildStateFnType, - StringResultWithCause, + RenderFnResultWithCause, path: String ); make_async_trait!( GetRequestStateFnType, - StringResultWithCause, + RenderFnResultWithCause, path: String, req: Request ); -make_async_trait!(ShouldRevalidateFnType, StringResultWithCause); +make_async_trait!(ShouldRevalidateFnType, RenderFnResultWithCause); // A series of closure types that should not be typed out more than once /// The type of functions that are given a state and render a page. If you've defined state for your page, it's safe to `.unwrap()` the @@ -144,7 +149,7 @@ pub type GetRequestStateFn = Rc; /// The type of functions that check if a template sghould revalidate. pub type ShouldRevalidateFn = Rc; /// The type of functions that amalgamate build and request states. -pub type AmalgamateStatesFn = Rc StringResultWithCause>>; +pub type AmalgamateStatesFn = Rc RenderFnResultWithCause>>; /// This allows the specification of all the template templates in an app and how to render them. If no rendering logic is provided at all, /// the template will be prerendered at build-time with no state. All closures are stored on the heap to avoid hellish lifetime specification. @@ -263,7 +268,7 @@ impl Template { fn_name: "get_build_paths".to_string(), template_name: self.get_path(), cause: ErrorCause::Server(None), - source: err.into(), + source: err, }), } } else { @@ -281,11 +286,11 @@ impl Template { let res = get_build_state.call(path).await; match res { Ok(res) => Ok(res), - Err((err, cause)) => Err(ServerError::RenderFnFailed { + Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { fn_name: "get_build_state".to_string(), template_name: self.get_path(), cause, - source: err.into(), + source: error, }), } } else { @@ -308,11 +313,11 @@ impl Template { let res = get_request_state.call(path, req).await; match res { Ok(res) => Ok(res), - Err((err, cause)) => Err(ServerError::RenderFnFailed { + Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { fn_name: "get_request_state".to_string(), template_name: self.get_path(), cause, - source: err.into(), + source: error, }), } } else { @@ -330,11 +335,11 @@ impl Template { let res = amalgamate_states(states); match res { Ok(res) => Ok(res), - Err((err, cause)) => Err(ServerError::RenderFnFailed { + Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { fn_name: "amalgamate_states".to_string(), template_name: self.get_path(), cause, - source: err.into(), + source: error, }), } } else { @@ -353,11 +358,11 @@ impl Template { let res = should_revalidate.call().await; match res { Ok(res) => Ok(res), - Err((err, cause)) => Err(ServerError::RenderFnFailed { + Err(GenericErrorWithCause { error, cause }) => Err(ServerError::RenderFnFailed { fn_name: "should_revalidate".to_string(), template_name: self.get_path(), cause, - source: err.into(), + source: error, }), } } else { From 4b933c77020c285c1747f3d33518d5fd21dad2ce Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 28 Sep 2021 17:01:58 +1000 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=F0=9F=90=9B=20fixed=20newlines/ta?= =?UTF-8?q?bs=20in=20initial=20state=20causing=20serialization=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We're now using JS raw strings, escaping as necessary, and then escaping control characters in the shell. --- packages/perseus-actix-web/src/initial_load.rs | 11 +++++++++-- packages/perseus/src/html_shell.rs | 11 +++++++---- packages/perseus/src/shell.rs | 10 ++++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/perseus-actix-web/src/initial_load.rs b/packages/perseus-actix-web/src/initial_load.rs index 62bc83ae19..c358060c13 100644 --- a/packages/perseus-actix-web/src/initial_load.rs +++ b/packages/perseus-actix-web/src/initial_load.rs @@ -33,9 +33,16 @@ fn return_error_page( }) .unwrap(); // Add a global variable that defines this as an error + // TODO fix lack of support for `\"` here (causes an error) let state_var = format!( - "", - error_page_data.replace(r#"'"#, r#"\'"#) // If we don't escape single quotes, we get runtime syntax errors + "", + error_page_data + // We escape any backslashes to prevent their interfering with JSON delimiters + .replace(r#"\"#, r#"\\"#) + // We escape any backticks, which would interfere with JS's raw strings system + .replace(r#"`"#, r#"\`"#) + // We escape any interpolations into JS's raw string system + .replace(r#"${"#, r#"\${"#) ); let html_with_declaration = html.replace("", &format!("{}\n", state_var)); // Interpolate the error page itself diff --git a/packages/perseus/src/html_shell.rs b/packages/perseus/src/html_shell.rs index 2f30c233b9..dc6fd1eeb5 100644 --- a/packages/perseus/src/html_shell.rs +++ b/packages/perseus/src/html_shell.rs @@ -48,12 +48,15 @@ pub fn interpolate_page_data(html_shell: &str, page_data: &PageData, root_id: &s // Interpolate a global variable of the state so the app shell doesn't have to make any more trips // The app shell will unset this after usage so it doesn't contaminate later non-initial loads // Error pages (above) will set this to `error` - let state_var = format!("", { + let state_var = format!("", { if let Some(state) = &page_data.state { state - // If we don't escape quotes, we get runtime syntax errors - .replace(r#"'"#, r#"\'"#) - .replace(r#"""#, r#"\""#) + // We escape any backslashes to prevent their interfering with JSON delimiters + .replace(r#"\"#, r#"\\"#) + // We escape any backticks, which would interfere with JS's raw strings system + .replace(r#"`"#, r#"\`"#) + // We escape any interpolations into JS's raw string system + .replace(r#"${"#, r#"\${"#) } else { "None".to_string() } diff --git a/packages/perseus/src/shell.rs b/packages/perseus/src/shell.rs index 96783ab7b0..898125af61 100644 --- a/packages/perseus/src/shell.rs +++ b/packages/perseus/src/shell.rs @@ -101,9 +101,15 @@ pub fn get_initial_state() -> InitialState { if state_str == "None" { InitialState::Present(None) } else if state_str.starts_with("error-") { - let err_page_data_str = state_str.strip_prefix("error-").unwrap(); + // We strip the prefix and escape any tab/newline control characters (inserted by `fmterr`) + // Any others are user-inserted, and this is documented + let err_page_data_str = state_str + .strip_prefix("error-") + .unwrap() + .replace("\n", "\\n") + .replace("\t", "\\t"); // There will be error page data encoded after `error-` - let err_page_data = match serde_json::from_str::(err_page_data_str) { + let err_page_data = match serde_json::from_str::(&err_page_data_str) { Ok(render_cfg) => render_cfg, // If there's a serialization error, we'll create a whole new error (500) Err(err) => ErrorPageData { From b84c14cf7ab0029578a0aab8b2060556a4ebbbe9 Mon Sep 17 00:00:00 2001 From: arctic_hen7 Date: Tue, 28 Sep 2021 19:03:15 +1000 Subject: [PATCH 10/10] =?UTF-8?q?docs(book):=20=F0=9F=93=9D=20updated=20do?= =?UTF-8?q?cs=20fro=20new=20error=20systems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/next/src/second-app.md | 60 ++++++++++++----------- docs/next/src/strategies/amlagamation.md | 4 +- docs/next/src/strategies/build-paths.md | 10 ++-- docs/next/src/strategies/build-state.md | 16 +++--- docs/next/src/strategies/request-state.md | 4 +- 5 files changed, 49 insertions(+), 45 deletions(-) diff --git a/docs/next/src/second-app.md b/docs/next/src/second-app.md index ee2aaaca93..fb5fd64fa8 100644 --- a/docs/next/src/second-app.md +++ b/docs/next/src/second-app.md @@ -1,12 +1,12 @@ # Your Second App -This section will cover building a more realistic app than the *Hello World!* section, with proper structuring and multiple templates. +This section will cover building a more realistic app than the _Hello World!_ section, with proper structuring and multiple templates. If learning by reading isn't really your thing, or you'd like a reference, you can see all the code in [this repository](https://github.com/arctic-hen7/perseus/tree/main/examples/basic)! ## Setup -Much like the *Hello World!* app, we'll start off by creating a new directory for the project, maybe `my-second-perseus-app` (or you could exercise imagination...). Then, we'll create a new `Cargo.toml` file and fill it with the following: +Much like the _Hello World!_ app, we'll start off by creating a new directory for the project, maybe `my-second-perseus-app` (or you could exercise imagination...). Then, we'll create a new `Cargo.toml` file and fill it with the following: ```toml {{#include ../../../examples/basic/Cargo.toml.example}} @@ -14,8 +14,8 @@ Much like the *Hello World!* app, we'll start off by creating a new directory fo The only difference between this and the last `Cargo.toml` we created is two new dependencies: -- [`serde`](https://serde.rs) -- a really useful Rust library for serializing/deserializing data -- [`serde_json`](https://github.com/serde-rs/json) -- Serde's integration for JSON, which lets us pass around properties for more advanced pages in Perseus +- [`serde`](https://serde.rs) -- a really useful Rust library for serializing/deserializing data +- [`serde_json`](https://github.com/serde-rs/json) -- Serde's integration for JSON, which lets us pass around properties for more advanced pages in Perseus The next thing to do is to create `index.html`, which is pretty much the same as last time: @@ -35,7 +35,7 @@ As in every Perseus app, `lib.rs` is how we communicate with the CLI and tell it This code is quite different from your first app, so let's go through how it works. -First, we define two other modules in our code: `error_pages` (at `src/error_pages.rs`) and `templates` (at `src/templates`). Don't worry, we'll create those in a moment. The rest of the code creates a new app with two templates, which are expected to be in the `src/templates` directory. Note the use of `` here, which is a Rust *type parameter* (the `get_template` function can work for the browser or the server, so Rust needs to know which one it is). This parameter is *ambient* to the `templates` key, which means you can use it without declaring it as long as you're inside `templates: {...}`. This will be set to `DomNode` for the browser and `SsrNode` for the server, but that all happens behind the scenes. +First, we define two other modules in our code: `error_pages` (at `src/error_pages.rs`) and `templates` (at `src/templates`). Don't worry, we'll create those in a moment. The rest of the code creates a new app with two templates, which are expected to be in the `src/templates` directory. Note the use of `` here, which is a Rust _type parameter_ (the `get_template` function can work for the browser or the server, so Rust needs to know which one it is). This parameter is _ambient_ to the `templates` key, which means you can use it without declaring it as long as you're inside `templates: {...}`. This will be set to `DomNode` for the browser and `SsrNode` for the server, but that all happens behind the scenes. Also note that we're pulling in our error pages from another file as well (in a larger app you may even want to have a different file for each error page). @@ -53,7 +53,7 @@ This is a little more advanced than the last time we did this, and there are a f The first is the import of `GenericNode`, which we define as a type parameter on the `get_error_pages` function. As we said before, this means your error pages will work on the client or the server, and they're needed in both environments. If you're interested, this separation of browser and server elements is done by Sycamore, and you can learn more about it [here](https://docs.rs/sycamore/0.6/sycamore/generic_node/trait.GenericNode.html). -In this function, we also define a different error page for a 404 error, which will occur when a user tries to go to a page that doesn't exist. The fallback page (which we initialize `ErrorPages` with) is the same as last time, and will be called for any errors other than a *404 Not Found*. +In this function, we also define a different error page for a 404 error, which will occur when a user tries to go to a page that doesn't exist. The fallback page (which we initialize `ErrorPages` with) is the same as last time, and will be called for any errors other than a _404 Not Found_. ## `index.rs` @@ -63,7 +63,7 @@ It's time to create the first page for this app! But first, we need to make sure {{#include ../../../examples/basic/src/templates/mod.rs}} ``` -It's common practice to have a file for each *template*, which is slightly different to a page (explained in more detail later), and this app has two pages: a landing page (index) and an about page. +It's common practice to have a file for each _template_, which is slightly different to a page (explained in more detail later), and this app has two pages: a landing page (index) and an about page. Let's begin with the landing page. Create a new file `src/templates/index.rs` and put the following inside: @@ -71,22 +71,22 @@ Let's begin with the landing page. Create a new file `src/templates/index.rs` an {{#include ../../../examples/basic/src/templates/index.rs}} ``` -This code is *much* more complex than the *Hello World!* example, so let's go through it carefully. +This code is _much_ more complex than the _Hello World!_ example, so let's go through it carefully. First, we import a whole ton of stuff: -- `perseus` - - `StringResultWithCause` -- see below for an explanation of this - - `Template` -- as before - - `GenericNode` -- as before -- `serde` - - `Serialize` -- a trait for `struct`s that can be turned into a string (like JSON) - - `Deserialize` -- a trait for `struct`s that can be *de*serialized from a string (like JSON) -- `std::rc::Rc` -- same as before, you can read more about `Rc`s [here](https://doc.rust-lang.org/std/rc/struct.Rc.html) -- `sycamore` - - `component` -- a macro that turns a function into a Sycamore component - - `template` -- the `template!` macro, same as before - - `Template as SycamoreTemplate` -- the output of the `template!` macro, aliased as `SycamoreTemplate` so it doesn't conflict with `perseus::Template`, which is very different +- `perseus` + - `RenderFnResultWithCause` -- see below for an explanation of this + - `Template` -- as before + - `GenericNode` -- as before +- `serde` + - `Serialize` -- a trait for `struct`s that can be turned into a string (like JSON) + - `Deserialize` -- a trait for `struct`s that can be *de*serialized from a string (like JSON) +- `std::rc::Rc` -- same as before, you can read more about `Rc`s [here](https://doc.rust-lang.org/std/rc/struct.Rc.html) +- `sycamore` + - `component` -- a macro that turns a function into a Sycamore component + - `template` -- the `template!` macro, same as before + - `Template as SycamoreTemplate` -- the output of the `template!` macro, aliased as `SycamoreTemplate` so it doesn't conflict with `perseus::Template`, which is very different Then we define a number of different functions and a `struct`, each of which gets a section now. @@ -104,19 +104,19 @@ Note that this takes `IndexPageProps` as an argument, which it can then access i The only other thing we do here is define an `` (an HTML link) to `/about`. This link, and any others you define, will automatically be detected by Sycamore's systems, which will pass them to Perseus' routing logic, which means your users **never leave the page**. In this way, Perseus only pulls in the content that needs to change, and gives your users the feeling of a lightning-fast and weightless app. -*Note: external links will automatically be excluded from this, and you can exclude manually by adding `rel="external"` if you need.* +_Note: external links will automatically be excluded from this, and you can exclude manually by adding `rel="external"` if you need._ ### `get_template()` This function is what we call in `lib.rs`, and it combines everything else in this file to produce an actual Perseus `Template` to be used. Note the name of the template as `index`, which Perseus interprets as special, which causes this template to be rendered at `/` (the landing page). -Perseus' templating system is extremely versatile, and here we're using it to define our page itself through `.template()`, and to define a function that will modify the document `` (which allows us to add a title) with `.head()`. Notably, we also use the *build state* rendering strategy, which tells Perseus to call the `get_build_props()` function when your app builds to get some state. More on that now. +Perseus' templating system is extremely versatile, and here we're using it to define our page itself through `.template()`, and to define a function that will modify the document `` (which allows us to add a title) with `.head()`. Notably, we also use the _build state_ rendering strategy, which tells Perseus to call the `get_build_props()` function when your app builds to get some state. More on that now. ### `get_build_props()` -This function is part of Perseus' secret sauce (actually *open* sauce), and it will be called when the CLI builds your app to create properties that the template will take (it expects a string, hence the serialization). Here, we just hard-code a greeting in to be used, but the real power of this comes when you start using the fact that this function is `async`. You might query a database to get a list of blog posts, or pull in a Markdown documentation page and parse it, the possibilities are endless! +This function is part of Perseus' secret sauce (actually _open_ sauce), and it will be called when the CLI builds your app to create properties that the template will take (it expects a string, hence the serialization). Here, we just hard-code a greeting in to be used, but the real power of this comes when you start using the fact that this function is `async`. You might query a database to get a list of blog posts, or pull in a Markdown documentation page and parse it, the possibilities are endless! -Note that this function returns a `StringResultWithCause`, which means that it returns a normal Rust `Result`, where `E` is a tuple of a `String` error message and a declaration of who caused the error (either the client or the server). This becomes important when you combine this rendering strategy with others, which are explained in depth later in the book. +Note that this function returns a `RenderFnResultWithCause`, which means that it returns a normal Rust `Result`, where `E` is a `GenericErrorWithCause`, a Perseus type that combines an arbitrary error message with a declaration of who caused the error (either the client or the server). This becomes important when you combine this rendering strategy with others, which are explained in depth later in the book. Note that we use `?` in this example on errors from modules like `serde_json`, showing how versatile this type is. If you don't explicitly construct `GenericErrorWithCause`, blame for the error will be assigned to the server, resulting in a _500 Internal Server Error_ HTTP status code. ### `template_fn()` @@ -128,6 +128,12 @@ This is very similar to `template_fn`, except it can't be reactive. In other wor All this does though is set the ``. If you inspect the source code of the HTML in your browser, you'll find a big comment in the `<head>` that says `<!--PERSEUS_INTERPOLATED_HEAD_BEGINS-->`, that separates the stuff that should remain the same on every page from the stuff that should update for each page. +### `set_headers_fn()` + +This function represents a very powerful feature of Perseus, the ability to set any [HTTP headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) for a given template. In this case, any time the Perseus server successfully returns our template to the browser, it will call this function on the HTTP response just before it sends it, which will add our custom header `x-test`, setting it to the value `custom value`. + +Note that this function has its own special return type, and that `HeaderMap` is distinct from other types, like a `HashMap`. + ## `about.rs` Okay! We're past the hump, and now it's time to define the (much simpler) `/about` page. Create `src/templates/about.rs` and put the following inside: @@ -146,14 +152,14 @@ That's all. Every time you build a Perseus app, that's all you need to do. Once this is finished, your app will be live at <http://localhost:8080>! Note that if you don't like that, you can change the host/port with the `HOST`/`PORT` environment variables (e.g. you'd want to set the host to `0.0.0.0` if you want other people on your network to be able to access your site). -Hop over to <http://localhost:8080> in any modern browser and you should see your greeting `Hello World!` above a link to the about page! if you click that link, you'll be taken to a page that just says `About.`, but notice how your browser seemingly never navigates to a new page (the tab doesn't show a loading icon)? That's Perseus' *app shell* in action, which intercepts navigation to other pages and makes it occur seamlessly, only fetching the bare minimum to make the new page load. The same behavior will occur if you use your browser's forward/backward buttons. +Hop over to <http://localhost:8080> in any modern browser and you should see your greeting `Hello World!` above a link to the about page! if you click that link, you'll be taken to a page that just says `About.`, but notice how your browser seemingly never navigates to a new page (the tab doesn't show a loading icon)? That's Perseus' _app shell_ in action, which intercepts navigation to other pages and makes it occur seamlessly, only fetching the bare minimum to make the new page load. The same behavior will occur if you use your browser's forward/backward buttons. <details> <summary>Why a 'modern browser'?</summary> ### Browser Compatibility -Perseus is compatible with any browser that supports Wasm, which is most modern browsers like Firefox and Chrome. However, legacy browsers like Internet Explorer will not work with any Perseus app, unless you *polyfill* support for WebAssembly. +Perseus is compatible with any browser that supports Wasm, which is most modern browsers like Firefox and Chrome. However, legacy browsers like Internet Explorer will not work with any Perseus app, unless you _polyfill_ support for WebAssembly. </details> @@ -165,8 +171,6 @@ By the way, remember this little bit of code in `src/lib.rs`? If you navigate to <http://localhost:8080/test.txt>, you should see the contents on `static/test.txt`! You can also access them at <http://localhost:8080/.perseus/static/test.txt> - - ## Moving Forward Congratulations! You're now well on your way to building highly performant web apps in Rust! The remaining sections of this book are more reference-style, and won't guide you through building an app, but they'll focus instead on specific features of Perseus that can be used to make extremely powerful systems. diff --git a/docs/next/src/strategies/amlagamation.md b/docs/next/src/strategies/amlagamation.md index 5533cc8b78..4b051e933b 100644 --- a/docs/next/src/strategies/amlagamation.md +++ b/docs/next/src/strategies/amlagamation.md @@ -1,6 +1,6 @@ # State Amalgamation -In the introduction to this section, we mentioned that all these rendering strategies are compatible with one another, though we didn't explain how the two strategies that generate unique properties for a template can possible be compatible. That is, how can you use *build state* and *request state* in the same template? To our knowledge, Perseus is the only framework in the world (in any language) that supports using both, and it's made possible by *state amalgamation*, which lets you provide an arbitrary function that can merge conflicting states from these two strategies! +In the introduction to this section, we mentioned that all these rendering strategies are compatible with one another, though we didn't explain how the two strategies that generate unique properties for a template can possible be compatible. That is, how can you use _build state_ and _request state_ in the same template? To our knowledge, Perseus is the only framework in the world (in any language) that supports using both, and it's made possible by _state amalgamation_, which lets you provide an arbitrary function that can merge conflicting states from these two strategies! ## Usage @@ -10,4 +10,4 @@ Here's an example from [here](https://github.com/arctic-hen7/perseus/blob/main/e {{#include ../../../../examples/showcase/src/templates/amalgamation.rs}} ``` -This example illustrates a very simple amalgamation, taking the states of both strategies to produce a new state that combines the two. Note that this also uses `StringResultWithCause` as a return type (see the section on the [*build state*](./build-state.md) strategy for more information). It will be passed an instance of `States`, which you can learn more about in the [API docs](https://docs.rs/perseus). +This example illustrates a very simple amalgamation, taking the states of both strategies to produce a new state that combines the two. Note that this also uses `RenderFnWithCause` as a return type (see the section on the [_build state_](./build-state.md) strategy for more information). It will be passed an instance of `States`, which you can learn more about in the [API docs](https://docs.rs/perseus). diff --git a/docs/next/src/strategies/build-paths.md b/docs/next/src/strategies/build-paths.md index 91317da6fc..7e52da9ba8 100644 --- a/docs/next/src/strategies/build-paths.md +++ b/docs/next/src/strategies/build-paths.md @@ -1,17 +1,17 @@ # Build Paths -As touched on in the documentation on the *build state* strategy, Perseus can easily turn one template into many pages (e.g. one blog post template into many blog post pages) with the *build paths* strategy, which is a function that returns a `Vec<String>` of paths to build. +As touched on in the documentation on the _build state_ strategy, Perseus can easily turn one template into many pages (e.g. one blog post template into many blog post pages) with the _build paths_ strategy, which is a function that returns a `Vec<String>` of paths to build. -Note that it's often unwise to use this strategy to render all your blog posts or the like, but only render the top give most commonly accessed or the like, if any at all. This is relevant mostly when you have a large number of pages to be generated. The *incremental generation* strategy is better suited for this, and it also allows you to never need to rebuild your site for new content (as long as the server can access the new content). +Note that it's often unwise to use this strategy to render all your blog posts or the like, but only render the top give most commonly accessed or the like, if any at all. This is relevant mostly when you have a large number of pages to be generated. The _incremental generation_ strategy is better suited for this, and it also allows you to never need to rebuild your site for new content (as long as the server can access the new content). -Note that, like *build state*, this strategy may be invoked at build-time or while the server is running if you use the *revalidation* strategy (*incremental generation* doesn't affect *build paths* though). +Note that, like _build state_, this strategy may be invoked at build-time or while the server is running if you use the _revalidation_ strategy (_incremental generation_ doesn't affect _build paths_ though). ## Usage -Here's the same example as given in the previous section (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/post.rs)), which uses *build paths* together with *build state* and *incremental generation*: +Here's the same example as given in the previous section (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/post.rs)), which uses _build paths_ together with _build state_ and _incremental generation_: ```rust,no_run,no_playground {{#include ../../../../examples/showcase/src/templates/post.rs}} ``` -Note the return type of the `get_build_paths` function, which returns a vector of `String`s on success or a `String` error. +Note the return type of the `get_build_paths` function, which returns a `RenderFnResult<Vec<String>>`, which is just an alias for `Result<T, Box<dyn std::error::Error>>`, which means that you can return any error you want. If you need to explicitly `return Err(..)`, then you should use `.into()` to perform the conversion from your error type to this type automatically. Perseus will then format your errors nicely for you using [`fmterr`](https://github.com/arctic-hen7/fmterr). diff --git a/docs/next/src/strategies/build-state.md b/docs/next/src/strategies/build-state.md index 72a637527b..3c61e994f9 100644 --- a/docs/next/src/strategies/build-state.md +++ b/docs/next/src/strategies/build-state.md @@ -2,13 +2,13 @@ The most commonly-used rendering strategy for Perseus is static generation, which renders your pages to static HTML files. These can then be served by the server with almost no additional processing, which makes for an extremely fast experience! -Note that, depending on other strategies used, Perseus may call this strategy at build-time or while the server is running, so you shouldn't depend on anything only present in a build environment (particularly if you're using the *incremental generation* or *revalidation* strategies). +Note that, depending on other strategies used, Perseus may call this strategy at build-time or while the server is running, so you shouldn't depend on anything only present in a build environment (particularly if you're using the _incremental generation_ or _revalidation_ strategies). -*Note: Perseus currently still requires a server if you want to export to purely static files, though standalone exports may be added in a future release. In the meantime, check out [Zola](https://getzola.org), which does pure static generation fantastically.* +_Note: Perseus currently still requires a server if you want to export to purely static files, though standalone exports may be added in a future release. In the meantime, check out [Zola](https://getzola.org), which does pure static generation fantastically._ ## Usage -### Without *Build Paths* or *Incremental Generation* +### Without _Build Paths_ or _Incremental Generation_ On its own, this strategy will simply generate properties for your template to turn it into a page, which would be perfect for something like a list of blog posts (just fetch the list from the filesystem, a database, etc.). Here's an example from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/index.rs) for a simple greeting: @@ -16,16 +16,16 @@ On its own, this strategy will simply generate properties for your template to t {{#include ../../../../examples/showcase/src/templates/index.rs}} ``` -Note that Perseus passes around properties to pages as `String`s, so the function used for this strategy is expected to return a string. Note also the return type `StringResultWithCause`, which means you can specify an error as `(String, perseus::errors::ErrorCause)`, the later part of which can either be `Client(Option<u16>)` or `Server(Option<u16>)`. The `u16`s allow specifying a custom HTTP status code, otherwise the defaults are *400* and *500* respectively. This return type allows specifying who's responsible for an error. This is irrelevant if you use this strategy on its own or with *build paths*, but if you bring in *incremental generation*, this will be necessary. +Note that Perseus passes around properties to pages as `String`s, so the function used for this strategy is expected to return a string. Note also the return type `RenderFnResultWithCause`, a Perseus type that represents the possibility of returning almost any kind of error, with an attached cause declaration that blames either the client or the server for the error. Most of the time, the server will be at fault (e.g. if serializing some obvious properties fails), and this is the default if you use `?` or `.into()` on another error type to run an automatic conversion. However, if you want to explicitly state a different cause (or provide a different HTTP status code), you can construct `GenericErrorWithCause`, as done in the below example (under the next subheading) if the path is `post/tests`. We set the error (a `Box<dyn std::error::Error>`) and then set the cause to be the client (they navigated to an illegal page) and tell the server to return a 404, which means our app will display something like _Page not found_. -### With *Build Paths* or *Incremental Generation* +### With _Build Paths_ or _Incremental Generation_ -You may have noticed in the above example that the build state function takes a `path` parameter. This becomes useful once you bring the *build paths* or *incremental generation* strategies into play, which allow you to render many paths for a single template. In the following example (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/post.rs)), all three strategies are used together to pre-render some blog posts at build-time, and allow the rest to be requested and rendered if they exist (here, any post will exist except one called `tests`): +You may have noticed in the above example that the build state function takes a `path` parameter. This becomes useful once you bring the _build paths_ or _incremental generation_ strategies into play, which allow you to render many paths for a single template. In the following example (taken from [here](https://github.com/arctic-hen7/perseus/blob/main/examples/showcase/src/templates/post.rs)), all three strategies are used together to pre-render some blog posts at build-time, and allow the rest to be requested and rendered if they exist (here, any post will exist except one called `tests`): ```rust,no_run,no_playground {{#include ../../../../examples/showcase/src/templates/post.rs}} ``` -When either of these additional strategies are used, *build state* will be passed the path of the page to be rendered, which allows it to prepare unique properties for that page. In the above example, it just turns the URL into a title and renders that. +When either of these additional strategies are used, _build state_ will be passed the path of the page to be rendered, which allows it to prepare unique properties for that page. In the above example, it just turns the URL into a title and renders that. -For further details on *build paths* and *incremental generation*, see the following sections. +For further details on _build paths_ and _incremental generation_, see the following sections. diff --git a/docs/next/src/strategies/request-state.md b/docs/next/src/strategies/request-state.md index 57aa8f59f2..13aa5c7e2f 100644 --- a/docs/next/src/strategies/request-state.md +++ b/docs/next/src/strategies/request-state.md @@ -1,6 +1,6 @@ # Request State -While build-time strategies fulfill many use-cases, there are also scenarios in which you may need access to information only available at request-time, like an authentication key that the client sends over HTTP as a cookie. For these cases, Perseus supports the *request state* strategy, which is akin to traditional server-side rendering, whereby you render the page when a client requests it. +While build-time strategies fulfill many use-cases, there are also scenarios in which you may need access to information only available at request-time, like an authentication key that the client sends over HTTP as a cookie. For these cases, Perseus supports the _request state_ strategy, which is akin to traditional server-side rendering, whereby you render the page when a client requests it. If you can avoid this strategy, do, because it will bring your app's TTFB (time to first byte) down, remember that anything done in this strategy is done on the server while the client is waiting for a page. @@ -12,7 +12,7 @@ Here's an example taken from [here](https://github.com/arctic-hen7/perseus/blob/ {{#include ../../../../examples/showcase/src/templates/ip.rs}} ``` -Note that, just like *build state*, this strategy generates stringified properties that will be passed to the page to render it, and it also uses `StringResultWithCause` (see the section on [build state](./build-state.md) for more information). The key difference though is that this strategy receives a second, very powerful parameter: the HTTP request that the user sent (`perseus::Request`). +Note that, just like _build state_, this strategy generates stringified properties that will be passed to the page to render it, and it also uses `RenderFnWithCause` (see the section on [build state](./build-state.md) for more information). The key difference though is that this strategy receives a second, very powerful parameter: the HTTP request that the user sent (`perseus::Request`). <details> <summary>How do you get the user's request information?</summary>