Merge branch 'master' into rework-errors
This commit is contained in:
commit
1e30712726
|
@ -14,8 +14,9 @@ categories = ["network-programming", "asynchronous"]
|
|||
readme = "README.md"
|
||||
|
||||
[features]
|
||||
default = ["apollo_tracing", "uuid", "bson", "chrono", "chrono-tz", "log", "multipart", "tracing", "url", "unblock", "string_number"]
|
||||
default = ["apollo_tracing", "apollo_persisted_queries", "uuid", "bson", "chrono", "chrono-tz", "log", "multipart", "tracing", "url", "unblock", "string_number"]
|
||||
apollo_tracing = ["chrono"]
|
||||
apollo_persisted_queries = ["lru"]
|
||||
multipart = ["multer", "bytes", "tempfile"]
|
||||
unblock = ["blocking"]
|
||||
string_number = ["num-traits"]
|
||||
|
@ -50,6 +51,7 @@ log = { version = "0.4.11", optional = true }
|
|||
tracing = { version = "0.1.19", optional = true }
|
||||
url = { version = "2.1.1", optional = true }
|
||||
num-traits = { version = "0.2.12", optional = true }
|
||||
lru = { version = "0.6.0", optional = true }
|
||||
|
||||
bytes = { version = "0.5.4", optional = true }
|
||||
multer = { version = "1.2.2", optional = true }
|
||||
|
|
16
README.md
16
README.md
|
@ -52,21 +52,7 @@ This crate uses `#![forbid(unsafe_code)]` to ensure everything is implemented in
|
|||
* Error Extensions
|
||||
* Apollo Federation
|
||||
* Batch Queries
|
||||
|
||||
# Crate features
|
||||
|
||||
This crate offers the following features, all of which are activated by default:
|
||||
|
||||
- `apollo_tracing`: Enable the [Apollo tracing extension](extensions/struct.ApolloTracing.html).
|
||||
- `log`: Enable the [logger extension](extensions/struct.Logger.html).
|
||||
- `tracing`: Enable the [tracing extension](extensions/struct.Tracing.html).
|
||||
- `multipart`: Support [sending files over HTTP multipart](http/fn.receive_body.html).
|
||||
- `unblock`: Support [asynchronous reader for Upload](types/struct.Upload.html)
|
||||
- `bson`: Integrate with the [`bson` crate](https://crates.io/crates/bson).
|
||||
- `chrono`: Integrate with the [`chrono` crate](https://crates.io/crates/chrono).
|
||||
- `chrono-tz`: Integrate with the [`chrono-tz` crate](https://crates.io/crates/chrono-tz).
|
||||
- `url`: Integrate with the [`url` crate](https://crates.io/crates/url).
|
||||
- `uuid`: Integrate with the [`uuid` crate](https://crates.io/crates/uuid).
|
||||
* Apollo Persisted Queries
|
||||
|
||||
## Examples
|
||||
|
||||
|
|
|
@ -92,7 +92,7 @@ pub fn generate(
|
|||
parse_graphql_attrs::<args::Argument>(&pat.attrs)?
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
pat.attrs.clear();
|
||||
remove_graphql_attrs(&mut pat.attrs);
|
||||
}
|
||||
(arg, Type::Reference(TypeReference { elem, .. })) => {
|
||||
if let Type::Path(path) = elem.as_ref() {
|
||||
|
@ -279,7 +279,7 @@ pub fn generate(
|
|||
parse_graphql_attrs::<args::Argument>(&pat.attrs)?
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
pat.attrs.clear();
|
||||
remove_graphql_attrs(&mut pat.attrs);
|
||||
}
|
||||
(arg, Type::Reference(TypeReference { elem, .. })) => {
|
||||
if let Type::Path(path) = elem.as_ref() {
|
||||
|
|
|
@ -27,3 +27,4 @@ Comparing Features of Other Rust GraphQL Implementations
|
|||
| Opentracing | 👍 | ⛔️ |
|
||||
| Apollo Federation | 👍 | ⛔️ |
|
||||
| Apollo Tracing | 👍 | ⛔️ |
|
||||
| Apollo Persisted Queries | 👍 | ⛔️ |
|
157
src/error.rs
157
src/error.rs
|
@ -237,163 +237,6 @@ impl<T> ExtendError for Result<T> {
|
|||
}
|
||||
}
|
||||
|
||||
/*
|
||||
/// An error processing a GraphQL query.
|
||||
#[derive(Debug, Error, PartialEq)]
|
||||
pub enum QueryError {
|
||||
/// The feature is not supported.
|
||||
#[error("Not supported.")]
|
||||
NotSupported,
|
||||
|
||||
/// The actual input type did not match the expected input type.
|
||||
#[error("Expected input type \"{expect}\", found {actual}.")]
|
||||
ExpectedInputType {
|
||||
/// The expected input type.
|
||||
expect: String,
|
||||
|
||||
/// The actual input type.
|
||||
actual: Value,
|
||||
},
|
||||
|
||||
/// Parsing of an input value failed.
|
||||
#[error("Failed to parse input value: {reason}")]
|
||||
ParseInputValue {
|
||||
/// The reason for the failure to resolve.
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// A field was not found on an object type.
|
||||
#[error("Cannot query field \"{field_name}\" on type \"{object}\".")]
|
||||
FieldNotFound {
|
||||
/// Field name
|
||||
field_name: String,
|
||||
|
||||
/// Object name
|
||||
object: String,
|
||||
},
|
||||
|
||||
/// `operation_name` in the request was required but not provided.
|
||||
#[error("Operation name required in request")]
|
||||
RequiredOperationName,
|
||||
|
||||
/// The operation name was unknown.
|
||||
#[error("Unknown operation named \"{name}\"")]
|
||||
UnknownOperationNamed {
|
||||
/// Operation name for query.
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// The user attempted to query an object without selecting any subfields.
|
||||
#[error("Type \"{object}\" must have a selection of subfields.")]
|
||||
MustHaveSubFields {
|
||||
/// Object name
|
||||
object: String,
|
||||
},
|
||||
|
||||
/// The schema does not have mutations.
|
||||
#[error("Schema is not configured for mutations.")]
|
||||
NotConfiguredMutations,
|
||||
|
||||
/// The schema does not have subscriptions.
|
||||
#[error("Schema is not configured for subscriptions.")]
|
||||
NotConfiguredSubscriptions,
|
||||
|
||||
/// The value does not exist in the enum.
|
||||
#[error("Invalid value for enum \"{ty}\".")]
|
||||
InvalidEnumValue {
|
||||
/// Enum type name
|
||||
ty: String,
|
||||
|
||||
/// Enum value
|
||||
value: String,
|
||||
},
|
||||
|
||||
/// A required field in an input object was not present.
|
||||
#[error("Required field \"{field_name}\" for InputObject \"{object}\" does not exist.")]
|
||||
RequiredField {
|
||||
/// Field name
|
||||
field_name: String,
|
||||
|
||||
/// Object name
|
||||
object: &'static str,
|
||||
},
|
||||
|
||||
/// A variable is used but not defined.
|
||||
#[error("Variable \"${var_name}\" is not defined")]
|
||||
VarNotDefined {
|
||||
/// Variable name
|
||||
var_name: String,
|
||||
},
|
||||
|
||||
/// A directive was required but not provided.
|
||||
#[error(
|
||||
"Directive \"{directive}\" argument \"{arg_name}\" of type \"{arg_type}\" is required, but it was not provided."
|
||||
)]
|
||||
RequiredDirectiveArgs {
|
||||
/// Directive name
|
||||
directive: &'static str,
|
||||
|
||||
/// Argument name
|
||||
arg_name: &'static str,
|
||||
|
||||
/// Argument type
|
||||
arg_type: &'static str,
|
||||
},
|
||||
|
||||
/// An unknown directive name was encountered.
|
||||
#[error("Unknown directive \"{name}\".")]
|
||||
UnknownDirective {
|
||||
/// Directive name
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// An unknown fragment was encountered.
|
||||
#[error("Unknown fragment \"{name}\".")]
|
||||
UnknownFragment {
|
||||
/// Fragment name
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// The query was too complex.
|
||||
// TODO: Expand on this
|
||||
#[error("Too complex")]
|
||||
TooComplex,
|
||||
|
||||
/// The query was nested too deep.
|
||||
#[error("Too deep")]
|
||||
TooDeep,
|
||||
|
||||
/// A field handler errored.
|
||||
#[error("Failed to resolve field: {err}")]
|
||||
Error {
|
||||
/// The error description.
|
||||
err: String,
|
||||
/// Extensions to the error provided through the [`ErrorExtensions`](trait.ErrorExtensions)
|
||||
/// or [`ResultExt`](trait.ResultExt) traits.
|
||||
extended_error: Option<serde_json::Value>,
|
||||
},
|
||||
|
||||
/// Entity not found.
|
||||
#[error("Entity not found")]
|
||||
EntityNotFound,
|
||||
|
||||
/// "__typename" must be an existing string.
|
||||
#[error("\"__typename\" must be an existing string")]
|
||||
TypeNameNotExists,
|
||||
}
|
||||
|
||||
impl QueryError {
|
||||
/// Convert this error to a regular `Error` type.
|
||||
pub fn into_error(self, pos: Pos) -> Error {
|
||||
Error::Query {
|
||||
pos,
|
||||
path: None,
|
||||
err: self,
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/// An error parsing the request.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
|
|
176
src/extensions/apollo_persisted_queries.rs
Normal file
176
src/extensions/apollo_persisted_queries.rs
Normal file
|
@ -0,0 +1,176 @@
|
|||
//! Apollo persisted queries extension.
|
||||
|
||||
use crate::extensions::{Error, Extension, ExtensionContext, ExtensionFactory};
|
||||
use crate::{Request, Result};
|
||||
use futures::lock::Mutex;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PersistedQuery {
|
||||
version: i32,
|
||||
#[serde(rename = "sha256Hash")]
|
||||
sha256_hash: String,
|
||||
}
|
||||
|
||||
/// Cache storage for persisted queries.
|
||||
#[async_trait::async_trait]
|
||||
pub trait CacheStorage: Send + Sync + Clone + 'static {
|
||||
/// Load the query by `key`.
|
||||
async fn get(&self, key: String) -> Option<String>;
|
||||
|
||||
/// Save the query by `key`.
|
||||
async fn set(&self, key: String, query: String);
|
||||
}
|
||||
|
||||
/// Memory-based LRU cache.
|
||||
#[derive(Clone)]
|
||||
pub struct LruCacheStorage(Arc<Mutex<lru::LruCache<String, String>>>);
|
||||
|
||||
impl LruCacheStorage {
|
||||
/// Creates a new LRU Cache that holds at most `cap` items.
|
||||
pub fn new(cap: usize) -> Self {
|
||||
Self(Arc::new(Mutex::new(lru::LruCache::new(cap))))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl CacheStorage for LruCacheStorage {
|
||||
async fn get(&self, key: String) -> Option<String> {
|
||||
let mut cache = self.0.lock().await;
|
||||
cache.get(&key).cloned()
|
||||
}
|
||||
|
||||
async fn set(&self, key: String, query: String) {
|
||||
let mut cache = self.0.lock().await;
|
||||
cache.put(key, query);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apollo persisted queries extension.
|
||||
///
|
||||
/// [Reference](https://www.apollographql.com/docs/react/api/link/persisted-queries/)
|
||||
#[cfg_attr(feature = "nightly", doc(cfg(feature = "apollo_persisted_queries")))]
|
||||
pub struct ApolloPersistedQueries<T>(T);
|
||||
|
||||
impl<T: CacheStorage> ApolloPersistedQueries<T> {
|
||||
/// Creates an apollo persisted queries extension.
|
||||
pub fn new(cache_storage: T) -> ApolloPersistedQueries<T> {
|
||||
Self(cache_storage)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: CacheStorage> ExtensionFactory for ApolloPersistedQueries<T> {
|
||||
fn create(&self) -> Box<dyn Extension> {
|
||||
Box::new(ApolloPersistedQueriesExtension {
|
||||
storage: self.0.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ApolloPersistedQueriesExtension<T> {
|
||||
storage: T,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<T: CacheStorage> Extension for ApolloPersistedQueriesExtension<T> {
|
||||
async fn prepare_request(
|
||||
&mut self,
|
||||
_ctx: &ExtensionContext<'_>,
|
||||
mut request: Request,
|
||||
) -> Result<Request> {
|
||||
if let Some(value) = request.extensions.remove("persistedQuery") {
|
||||
let persisted_query: PersistedQuery = serde_json::from_value(value).map_err(|_| {
|
||||
Error::Other("Invalid \"PersistedQuery\" extension configuration.".to_string())
|
||||
})?;
|
||||
if persisted_query.version != 1 {
|
||||
return Err(Error::Other (
|
||||
format!("Only the \"PersistedQuery\" extension of version \"1\" is supported, and the current version is \"{}\".", persisted_query.version),
|
||||
));
|
||||
}
|
||||
|
||||
if request.query.is_empty() {
|
||||
if let Some(query) = self.storage.get(persisted_query.sha256_hash).await {
|
||||
Ok(Request { query, ..request })
|
||||
} else {
|
||||
Err(Error::Other("PersistedQueryNotFound".to_string()))
|
||||
}
|
||||
} else {
|
||||
self.storage
|
||||
.set(persisted_query.sha256_hash, request.query.clone())
|
||||
.await;
|
||||
Ok(request)
|
||||
}
|
||||
} else {
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[async_std::test]
|
||||
async fn test() {
|
||||
use super::*;
|
||||
use crate::*;
|
||||
|
||||
struct Query;
|
||||
|
||||
#[Object(internal)]
|
||||
impl Query {
|
||||
async fn value(&self) -> i32 {
|
||||
100
|
||||
}
|
||||
}
|
||||
|
||||
let schema = Schema::build(Query, EmptyMutation, EmptySubscription)
|
||||
.extension(ApolloPersistedQueries::new(LruCacheStorage::new(256)))
|
||||
.finish();
|
||||
|
||||
let mut request = Request::new("{ value }");
|
||||
request.extensions.insert(
|
||||
"persistedQuery".to_string(),
|
||||
serde_json::json!({
|
||||
"version": 1,
|
||||
"sha256Hash": "abc",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
schema.execute(request).await.into_result().unwrap().data,
|
||||
serde_json::json!({
|
||||
"value": 100
|
||||
})
|
||||
);
|
||||
|
||||
let mut request = Request::new("");
|
||||
request.extensions.insert(
|
||||
"persistedQuery".to_string(),
|
||||
serde_json::json!({
|
||||
"version": 1,
|
||||
"sha256Hash": "abc",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
schema.execute(request).await.into_result().unwrap().data,
|
||||
serde_json::json!({
|
||||
"value": 100
|
||||
})
|
||||
);
|
||||
|
||||
let mut request = Request::new("");
|
||||
request.extensions.insert(
|
||||
"persistedQuery".to_string(),
|
||||
serde_json::json!({
|
||||
"version": 1,
|
||||
"sha256Hash": "def",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
schema.execute(request).await.into_result().unwrap_err(),
|
||||
Error::Other("PersistedQueryNotFound".to_string())
|
||||
);
|
||||
}
|
||||
}
|
|
@ -101,6 +101,12 @@ impl Extension for LoggerExtension {
|
|||
write!(f, "variables: {}", self.log.variables)?;
|
||||
write!(f, "{}", self.e.message)
|
||||
}
|
||||
Error::Other(err) => error!(
|
||||
target: "async-graphql", "[OtherError] query: \"{}\", variables: {}, {}",
|
||||
self.query,
|
||||
self.variables,
|
||||
err
|
||||
),
|
||||
}
|
||||
|
||||
error!(
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
//! Extensions for schema
|
||||
|
||||
#[cfg(feature = "apollo_persisted_queries")]
|
||||
pub mod apollo_persisted_queries;
|
||||
#[cfg(feature = "apollo_tracing")]
|
||||
mod apollo_tracing;
|
||||
#[cfg(feature = "log")]
|
||||
|
@ -8,7 +10,7 @@ mod logger;
|
|||
mod tracing;
|
||||
|
||||
use crate::context::{QueryPathNode, ResolveId};
|
||||
use crate::{Data, Result, ServerError, ServerResult, Variables};
|
||||
use crate::{Data, Request, Result, ServerError, ServerResult, Variables};
|
||||
|
||||
#[cfg(feature = "apollo_tracing")]
|
||||
pub use self::apollo_tracing::ApolloTracing;
|
||||
|
@ -88,6 +90,7 @@ pub struct ResolveInfo<'a> {
|
|||
}
|
||||
|
||||
/// Represents a GraphQL extension
|
||||
#[async_trait::async_trait]
|
||||
#[allow(unused_variables)]
|
||||
pub trait Extension: Sync + Send + 'static {
|
||||
/// If this extension needs to output data to query results, you need to specify a name.
|
||||
|
@ -95,6 +98,15 @@ pub trait Extension: Sync + Send + 'static {
|
|||
None
|
||||
}
|
||||
|
||||
/// Called at the prepare request
|
||||
async fn prepare_request(
|
||||
&mut self,
|
||||
ctx: &ExtensionContext<'_>,
|
||||
request: Request,
|
||||
) -> ServerResult<Request> {
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Called at the begin of the parse.
|
||||
fn parse_start(
|
||||
&mut self,
|
||||
|
@ -158,7 +170,20 @@ impl<T> ErrorLogger for Result<T, Vec<ServerError>> {
|
|||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Extension for Extensions {
|
||||
async fn prepare_request(
|
||||
&mut self,
|
||||
ctx: &ExtensionContext<'_>,
|
||||
request: Request,
|
||||
) -> ServerResult<Request> {
|
||||
let mut request = request;
|
||||
for e in self.0.iter_mut() {
|
||||
request = e.prepare_request(ctx, request).await?;
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn parse_start(
|
||||
&mut self,
|
||||
ctx: &ExtensionContext<'_>,
|
||||
|
|
|
@ -48,12 +48,14 @@
|
|||
//! * Error Extensions
|
||||
//! * Apollo Federation
|
||||
//! * Batch Queries
|
||||
//! * Apollo Persisted Queries
|
||||
//!
|
||||
//! # Crate features
|
||||
//!
|
||||
//! This crate offers the following features, all of which are activated by default:
|
||||
//!
|
||||
//! - `apollo_tracing`: Enable the [Apollo tracing extension](extensions/struct.ApolloTracing.html).
|
||||
//! - `apollo_persisted_queries`: Enable the [Apollo persisted queries extension](extensions/apollo_persisted_queries/struct.ApolloPersistedQueries.html).
|
||||
//! - `log`: Enable the [logger extension](extensions/struct.Logger.html).
|
||||
//! - `tracing`: Enable the [tracing extension](extensions/struct.Tracing.html).
|
||||
//! - `multipart`: Support [sending files over HTTP multipart](http/fn.receive_body.html).
|
||||
|
|
|
@ -2,6 +2,7 @@ use crate::parser::types::UploadValue;
|
|||
use crate::{Data, ParseRequestError, Value, Variables};
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::any::Any;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{self, Debug, Formatter};
|
||||
use std::fs::File;
|
||||
|
||||
|
@ -29,6 +30,10 @@ pub struct Request {
|
|||
/// **This data is only valid for this request**
|
||||
#[serde(skip)]
|
||||
pub data: Data,
|
||||
|
||||
/// The extensions config of the request.
|
||||
#[serde(default)]
|
||||
pub extensions: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn deserialize_variables<'de, D: Deserializer<'de>>(
|
||||
|
@ -45,6 +50,7 @@ impl Request {
|
|||
operation_name: None,
|
||||
variables: Variables::default(),
|
||||
data: Data::default(),
|
||||
extensions: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ use std::pin::Pin;
|
|||
|
||||
/// A GraphQL container.
|
||||
///
|
||||
/// This helper trait allows the type to call `resolve_object` on itself in its
|
||||
/// This helper trait allows the type to call `resolve_container` on itself in its
|
||||
/// `OutputValueType::resolve` implementation.
|
||||
#[async_trait::async_trait]
|
||||
pub trait ContainerType: OutputValueType {
|
||||
|
@ -114,11 +114,11 @@ pub async fn resolve_container_serial<'a, T: ContainerType + Send + Sync>(
|
|||
type BoxFieldFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = ServerResult<(String, serde_json::Value)>> + 'a + Send>>;
|
||||
|
||||
/// A set of fields on an object that are being selected.
|
||||
/// A set of fields on an container that are being selected.
|
||||
pub struct Fields<'a>(Vec<BoxFieldFuture<'a>>);
|
||||
|
||||
impl<'a> Fields<'a> {
|
||||
/// Add another set of fields to this set of fields using the given object.
|
||||
/// Add another set of fields to this set of fields using the given container.
|
||||
pub fn add_set<T: ContainerType + Send + Sync>(
|
||||
&mut self,
|
||||
ctx: &ContextSelectionSet<'a>,
|
||||
|
|
|
@ -319,7 +319,7 @@ where
|
|||
Self::create_registry().export_sdl(false)
|
||||
}
|
||||
|
||||
fn prepare_request(
|
||||
async fn prepare_request(
|
||||
&self,
|
||||
request: Request,
|
||||
) -> Result<(QueryEnvInner, CacheControl), Vec<ServerError>> {
|
||||
|
@ -331,6 +331,18 @@ where
|
|||
.map(|factory| factory.create())
|
||||
.collect_vec(),
|
||||
));
|
||||
|
||||
let request = extensions
|
||||
.lock()
|
||||
.prepare_request(
|
||||
&ExtensionContext {
|
||||
schema_data: &self.env.data,
|
||||
query_data: &Default::default(),
|
||||
},
|
||||
request,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let ctx_extension = ExtensionContext {
|
||||
schema_data: &self.env.data,
|
||||
query_data: &request.data,
|
||||
|
@ -454,7 +466,7 @@ where
|
|||
/// Execute an GraphQL query.
|
||||
pub async fn execute(&self, request: impl Into<Request>) -> Response {
|
||||
let request = request.into();
|
||||
match self.prepare_request(request) {
|
||||
match self.prepare_request(request).await {
|
||||
Ok((env, cache_control)) => self
|
||||
.execute_once(QueryEnv::new(env))
|
||||
.await
|
||||
|
@ -485,7 +497,7 @@ where
|
|||
|
||||
async_stream::stream! {
|
||||
let request = request.into();
|
||||
let (mut env, cache_control) = match schema.prepare_request(request) {
|
||||
let (mut env, cache_control) = match schema.prepare_request(request).await {
|
||||
Ok(res) => res,
|
||||
Err(errors) => {
|
||||
yield Response::from_errors(errors);
|
||||
|
|
Loading…
Reference in New Issue
Block a user