Compare commits
1 Commits
Author | SHA1 | Date |
---|---|---|
Franklin | 7d2ac21539 |
|
@ -1,8 +0,0 @@
|
||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
/dataSources/
|
|
||||||
/dataSources.local.xml
|
|
|
@ -1,11 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="EMPTY_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
|
@ -1,8 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/err.iml" filepath="$PROJECT_DIR$/.idea/err.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AutoImportSettings">
|
||||||
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
|
</component>
|
||||||
|
<component name="CargoProjects">
|
||||||
|
<cargoProject FILE="$PROJECT_DIR$/Cargo.toml" />
|
||||||
|
</component>
|
||||||
|
<component name="ChangeListManager">
|
||||||
|
<list default="true" id="55b02629-36d6-41c0-bfde-4f4bcb168cc6" name="Changes" comment="">
|
||||||
|
<change afterPath="$PROJECT_DIR$/src/macros.rs" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/Cargo.toml" beforeDir="false" afterPath="$PROJECT_DIR$/Cargo.toml" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/src/lib.rs" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib.rs" afterDir="false" />
|
||||||
|
</list>
|
||||||
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
|
</component>
|
||||||
|
<component name="FileTemplateManagerImpl">
|
||||||
|
<option name="RECENT_TEMPLATES">
|
||||||
|
<list>
|
||||||
|
<option value="Rust File" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="Git.Settings">
|
||||||
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="MacroExpansionManager">
|
||||||
|
<option name="directoryName" value="pq0psn0s" />
|
||||||
|
</component>
|
||||||
|
<component name="MarkdownSettingsMigration">
|
||||||
|
<option name="stateVersion" value="1" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectColorInfo">{
|
||||||
|
"associatedIndex": 4
|
||||||
|
}</component>
|
||||||
|
<component name="ProjectId" id="2VtawilknlSRDe5peAyQTdli9E5" />
|
||||||
|
<component name="ProjectViewState">
|
||||||
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
|
<option name="showLibraryContents" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="PropertiesComponent">{
|
||||||
|
"keyToString": {
|
||||||
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"WebServerToolWindowFactoryState": "false",
|
||||||
|
"git-widget-placeholder": "master",
|
||||||
|
"last_opened_file_path": "/Users/franklinblanco/Desktop/Code/rust/libs/dev-deps/err",
|
||||||
|
"nodejs_package_manager_path": "npm",
|
||||||
|
"org.rust.cargo.project.model.PROJECT_DISCOVERY": "true",
|
||||||
|
"settings.editor.selected.configurable": "language.rust.cargo.check"
|
||||||
|
}
|
||||||
|
}</component>
|
||||||
|
<component name="RsExternalLinterProjectSettings">
|
||||||
|
<option name="runOnTheFly" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="RustProjectSettings">
|
||||||
|
<option name="toolchainHomeDirectory" value="$USER_HOME$/.cargo/bin" />
|
||||||
|
</component>
|
||||||
|
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
||||||
|
<component name="TaskManager">
|
||||||
|
<task active="true" id="Default" summary="Default task">
|
||||||
|
<changelist id="55b02629-36d6-41c0-bfde-4f4bcb168cc6" name="Changes" comment="" />
|
||||||
|
<created>1695658070613</created>
|
||||||
|
<option name="number" value="Default" />
|
||||||
|
<option name="presentableId" value="Default" />
|
||||||
|
<updated>1695658070613</updated>
|
||||||
|
<workItem from="1695658071623" duration="65000" />
|
||||||
|
<workItem from="1695658240004" duration="268000" />
|
||||||
|
<workItem from="1695658855669" duration="7475000" />
|
||||||
|
<workItem from="1695852710454" duration="2339000" />
|
||||||
|
<workItem from="1695868239967" duration="4585000" />
|
||||||
|
<workItem from="1695994876412" duration="614000" />
|
||||||
|
</task>
|
||||||
|
<servers />
|
||||||
|
</component>
|
||||||
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
<option name="version" value="3" />
|
||||||
|
</component>
|
||||||
|
<component name="XSLT-Support.FileAssociations.UIState">
|
||||||
|
<expand />
|
||||||
|
<select />
|
||||||
|
</component>
|
||||||
|
</project>
|
|
@ -12,9 +12,3 @@ repository = "https://github.com/franklinblanco/err.git"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
thiserror = "1.0.48"
|
|
||||||
sqlx = { version = "0.7", features = ["json"] }
|
|
||||||
stdext = "0.3.1"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
serde_json = { version = "1" }
|
|
181
src/lib.rs
181
src/lib.rs
|
@ -1,74 +1,5 @@
|
||||||
mod macros;
|
|
||||||
mod tests;
|
|
||||||
|
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use serde::{Serialize, Deserialize, Serializer};
|
use serde::{Serialize, Deserialize};
|
||||||
use serde::ser::SerializeMap;
|
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
pub use stdext::function_name;
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)]
|
|
||||||
pub struct Trace {
|
|
||||||
pub line: u32,
|
|
||||||
pub function: String,
|
|
||||||
pub file: String,
|
|
||||||
pub service: String,
|
|
||||||
}
|
|
||||||
impl Trace {
|
|
||||||
pub fn set_func_name(mut self, fn_name: impl ToString) -> Trace {
|
|
||||||
self.function = fn_name.to_string();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub struct Traces { pub traces: Vec<Trace> }
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Serialize, Debug, Error)]
|
|
||||||
#[error("MessageResource: {message_resource} ErrorType: {error_type} Trace: {trace:#?}")]
|
|
||||||
pub struct Error {
|
|
||||||
pub trace: Traces,
|
|
||||||
#[serde(rename = "messageResource")]
|
|
||||||
pub message_resource: MessageResource,
|
|
||||||
#[serde(rename = "errorType")]
|
|
||||||
pub error_type: ErrorType,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error {
|
|
||||||
pub fn new(trace: Trace) -> Self {
|
|
||||||
Self {
|
|
||||||
trace: Traces { traces: Vec::from([trace]) },
|
|
||||||
message_resource: MessageResource::new("errors.backend.common.default", "We still don't have an error defined for this."),
|
|
||||||
error_type: ErrorType::Unspecified,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn push_trace(mut self, trace: Trace) -> Self {
|
|
||||||
self.trace.traces.push(trace);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_error(mut self, error: Error) -> Self {
|
|
||||||
self.error_type = ErrorType::Nested { nested: Box::new(error) };
|
|
||||||
self
|
|
||||||
}
|
|
||||||
pub fn key(mut self, key: Option<impl ToString>) -> Self {
|
|
||||||
self.message_resource.key = match key {
|
|
||||||
None => None,
|
|
||||||
Some(key) => Some(key.to_string()),
|
|
||||||
};
|
|
||||||
self
|
|
||||||
}
|
|
||||||
pub fn message(mut self, message: impl ToString) -> Self {
|
|
||||||
self.message_resource.message = message.to_string();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
pub fn error_type(mut self, error_type: ErrorType) -> Self {
|
|
||||||
self.error_type = error_type;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This is for sending errors back from requests conveniently.
|
/// This is for sending errors back from requests conveniently.
|
||||||
/// This struct contains an optional key just in
|
/// This struct contains an optional key just in
|
||||||
|
@ -80,13 +11,11 @@ pub struct MessageResource {
|
||||||
pub key: Option<String>,
|
pub key: Option<String>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for MessageResource {
|
impl Display for MessageResource {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "MessageResource Key: {:#?}, Message: {}", self.key, self.message)
|
write!(f, "MessageResource Key: {:#?}, Message: {}", self.key, self.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageResource {
|
impl MessageResource {
|
||||||
pub fn new(key: &str, msg: &str) -> Self {
|
pub fn new(key: &str, msg: &str) -> Self {
|
||||||
Self { key: Some(key.to_string()), message: msg.to_string() }
|
Self { key: Some(key.to_string()), message: msg.to_string() }
|
||||||
|
@ -112,95 +41,27 @@ impl Default for MessageResource{
|
||||||
}
|
}
|
||||||
/// This is supposed to be used whenever you have an error in your code and want to be more specific about it.
|
/// This is supposed to be used whenever you have an error in your code and want to be more specific about it.
|
||||||
/// Fits in with most CRUD web apps. What you send back to the client is a MessageResource, not the error itself!
|
/// Fits in with most CRUD web apps. What you send back to the client is a MessageResource, not the error itself!
|
||||||
#[derive(Serialize, Debug, Error)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ErrorType {
|
pub enum Error {
|
||||||
#[error("Network Error")]
|
Network(MessageResource),
|
||||||
Network,
|
IO(MessageResource),
|
||||||
#[error("IO error")]
|
Privilege(MessageResource),
|
||||||
IO,
|
UnexpectedStatusCode(u16, u16, Vec<MessageResource>),
|
||||||
#[error("Privilege Error")]
|
Serde(MessageResource),
|
||||||
Privilege,
|
Parser(MessageResource),
|
||||||
#[error("Unexpected Status Code. Expected: {expected} Actual: {actual}")]
|
Unspecified
|
||||||
UnexpectedStatusCode { expected: u16, actual: u16 },
|
|
||||||
#[error("Serde Error. Attempted to Serialize/Deserialize String: {text}")]
|
|
||||||
Serde { text: String },
|
|
||||||
#[error("Parsing error.")]
|
|
||||||
Parser,
|
|
||||||
#[error("Service Error: {error}")]
|
|
||||||
Service { error: ServiceError },
|
|
||||||
#[error("Unspecified Error")]
|
|
||||||
Unspecified,
|
|
||||||
#[error("Unexpected Error: {message}")]
|
|
||||||
Unexpected { message: String },
|
|
||||||
#[error("Nested Error: {nested}")]
|
|
||||||
Nested { nested: Box<Error> },
|
|
||||||
}
|
}
|
||||||
|
impl Display for Error {
|
||||||
#[derive(Error, Serialize, Debug)]
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
pub enum ServiceError {
|
match self {
|
||||||
/// Used to return a simple error from FromStr implementations.
|
Error::Network(message) => write!(f, "Error of type Network. MessageResource: {message}"),
|
||||||
#[error("Error parsing string into value")]
|
Error::IO(message) => write!(f, "Error of type IO. MessageResource: {message}"),
|
||||||
FromStrError,
|
Error::Privilege(message) => write!(f, "Error of type Privilege. MessageResource: {message}"),
|
||||||
/// Every error that is returned from a DAO operation.
|
Error::UnexpectedStatusCode(expected, actual, messages) => write!(f, "Error of type UnexpectedStatusCode. Expected: {expected}, Actual: {actual}, MessageResources: {:#?}", messages),
|
||||||
#[error("Error from the Database: {error}")]
|
Error::Serde(message) => write!(f, "Error of type Serialization/Deserialization. MessageResource: {message}"),
|
||||||
#[serde(serialize_with = "ser_with")]
|
Error::Unspecified => write!(f, "Error of type Unspecified."),
|
||||||
DatabaseError {
|
Error::Parser(message) => write!(f, "Error of type Parser. MessageResource: {message}"),
|
||||||
#[from]
|
|
||||||
error: sqlx::Error
|
|
||||||
},
|
|
||||||
/// A vec of ValidationErrors
|
|
||||||
#[error("Error Operation Not Allowed: {message}")]
|
|
||||||
NotAllowed { message: String },
|
|
||||||
#[error("Validation Errors: {errors:?}")]
|
|
||||||
ValidationErrors { errors: Vec<ValidationError> },
|
|
||||||
/// Something already exists. That something should be {0}
|
|
||||||
/// Example: "User" "Credential"
|
|
||||||
#[error("Error {message} Already exists.")]
|
|
||||||
AlreadyExistsError { message: String },
|
|
||||||
/// Example: "User with id X"
|
|
||||||
#[error("{message} Not found.")]
|
|
||||||
NotFoundError { message: String },
|
|
||||||
/// Used to specify authentication error.
|
|
||||||
/// Example: Password incorrect for user
|
|
||||||
#[error("Credential supplied is incorrect. {message}")]
|
|
||||||
IncorrectCredentialError { message: String },
|
|
||||||
#[error("Too many credentials supplied, maximum is 3.")]
|
|
||||||
TooManyCredentialsError,
|
|
||||||
/// Used for anything else.
|
|
||||||
#[error("Unexpected Error: {message}")]
|
|
||||||
UnexpectedError { message: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Any string validation error such as Phone number validation or email, etc...
|
|
||||||
/// Reason should be a Key for internationalization
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Error)]
|
|
||||||
#[error("Error validating `{what}`. Reason: {reason}")]
|
|
||||||
pub struct ValidationError {
|
|
||||||
pub what: String,
|
|
||||||
pub reason: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ser_with<S>(id: &sqlx::Error, s: S) -> Result<S::Ok, S::Error> where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
let mut ser = s.serialize_map(Some(1))?;
|
|
||||||
ser.serialize_entry("$oid", &id.to_string())?;
|
|
||||||
ser.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait VecRemove {
|
|
||||||
type T;
|
|
||||||
fn try_remove(&mut self, index: usize) -> Option<Self::T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> VecRemove for Vec<T> {
|
|
||||||
type T = T;
|
|
||||||
|
|
||||||
fn try_remove(&mut self, index: usize) -> Option<Self::T> {
|
|
||||||
if index < self.len() {
|
|
||||||
Some(self.remove(index))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
impl std::error::Error for Error {}
|
|
@ -1,69 +0,0 @@
|
||||||
/// Macro used to generate the trace object, must be called from the place where it originates, don't call from another function.
|
|
||||||
#[allow(unused_macros)]
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! trace {
|
|
||||||
() => {
|
|
||||||
err::Trace {
|
|
||||||
line: line!(),
|
|
||||||
function: err::function_name!().into(),
|
|
||||||
file: file!().into(),
|
|
||||||
service: env!("CARGO_PKG_NAME").into(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Macro used to generate the trace object, must be called from the place where it originates, don't call from another function.
|
|
||||||
#[allow(unused_macros)]
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! test_trace {
|
|
||||||
() => {
|
|
||||||
crate::Trace {
|
|
||||||
line: line!(),
|
|
||||||
function: crate::function_name!().into(),
|
|
||||||
file: file!().into(),
|
|
||||||
service: env!("CARGO_PKG_NAME").into(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Macro used to 'unwrap' a result that returns a Error
|
|
||||||
///
|
|
||||||
/// If there's an error returns the generated Error and push a trace on it.
|
|
||||||
#[allow(unused_macros)]
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! u_res_or_res {
|
|
||||||
( $e:expr ) => {
|
|
||||||
match $e {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(mut error) => return Err(error.push_trace(err::trace!()))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Macro used to 'unwrap' a result that returns a Error
|
|
||||||
///
|
|
||||||
/// If there's an error returns the generated Error and push a trace on it.
|
|
||||||
#[allow(unused_macros)]
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! x_u_res_or_res {
|
|
||||||
( $e:expr, $t:expr ) => {
|
|
||||||
match $e {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(error) => return Err(err::Error::new(err::trace!()).message(error.to_string()).error_type($t))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Macro used to 'unwrap' a result that returns a Error
|
|
||||||
///
|
|
||||||
/// If there's an error returns the generated Error and push a trace on it.
|
|
||||||
#[allow(unused_macros)]
|
|
||||||
#[macro_export]
|
|
||||||
macro_rules! x_u_res_db_or_res {
|
|
||||||
( $e:expr ) => {
|
|
||||||
match $e {
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(error) => return Err(err::Error::new(err::trace!()).message(error.to_string()).error_type(err::ErrorType::Service { error: err::ServiceError::DatabaseError{error: error} } ))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
45
src/tests.rs
45
src/tests.rs
|
@ -1,45 +0,0 @@
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::{Error, ErrorType, ServiceError, test_trace};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn print_json() {
|
|
||||||
let error = Error::new(test_trace!())
|
|
||||||
.message("Hi there g")
|
|
||||||
.push_trace(test_trace!())
|
|
||||||
.key(Some("This key"))
|
|
||||||
.push_error(
|
|
||||||
Error::new(test_trace!())
|
|
||||||
.error_type(ErrorType::Service { error: ServiceError::AlreadyExistsError { message: String::from("Hey now") } })
|
|
||||||
.message("Hi there g")
|
|
||||||
.push_trace(test_trace!())
|
|
||||||
.key(Some("key for nested err"))
|
|
||||||
);
|
|
||||||
println!("Object in rust: {:#?}", error);
|
|
||||||
println!("");
|
|
||||||
println!("#############################################");
|
|
||||||
println!("");
|
|
||||||
println!("Object in json: {}", serde_json::to_string_pretty(&error).unwrap())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn print_json_with_db_error_variant() {
|
|
||||||
let error = Error::new(test_trace!())
|
|
||||||
.message("Hi there g")
|
|
||||||
.push_trace(test_trace!())
|
|
||||||
.key(Some("This key"))
|
|
||||||
.push_error(
|
|
||||||
Error::new(test_trace!())
|
|
||||||
.error_type(ErrorType::Service { error: ServiceError::DatabaseError { error: sqlx::Error::Protocol("AAAA".into()) } })
|
|
||||||
.message("Hi there g")
|
|
||||||
.push_trace(test_trace!())
|
|
||||||
.key(Some("key for nested err"))
|
|
||||||
);
|
|
||||||
println!("Object in rust: {:#?}", error);
|
|
||||||
println!("");
|
|
||||||
println!("#############################################");
|
|
||||||
println!("");
|
|
||||||
println!("Object in json: {}", serde_json::to_string_pretty(&error).unwrap())
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue