refactor: starting refactor to extract into rear crate
This commit is contained in:
51
rear/Cargo.toml
Normal file
51
rear/Cargo.toml
Normal file
@@ -0,0 +1,51 @@
|
||||
[package]
|
||||
name = "rear"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "rear"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
# https://github.com/rust-db/barrel/blob/master/guides/diesel-setup.md
|
||||
use_barrel = ["barrel", "sqlformat"]
|
||||
default = ["use_barrel"]
|
||||
|
||||
[dependencies]
|
||||
sea-orm = { version = "0.12.10", features = [
|
||||
"runtime-tokio-native-tls",
|
||||
"sqlx-postgres",
|
||||
] }
|
||||
sqlformat = { version = "0.2.2", optional = true }
|
||||
anyhow = "1.0.75"
|
||||
axum = "0.7"
|
||||
barrel = { version = "0.7.0", optional = true, features = ["pg"] }
|
||||
dotenvy = "0.15.7"
|
||||
mime_guess = "2.0.4"
|
||||
minijinja = { version = "1.0.11", features = [
|
||||
"loader",
|
||||
"builtins",
|
||||
"urlencode",
|
||||
"deserialization",
|
||||
] }
|
||||
minijinja-autoreload = "1.0.8"
|
||||
once_cell = "1.18.0"
|
||||
pulldown-cmark = "0.9.3"
|
||||
rust-embed = { version = "8.0.0", features = [
|
||||
"axum",
|
||||
"tokio",
|
||||
"include-exclude",
|
||||
] }
|
||||
serde = { version = "1.0.188", features = ["derive"] }
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
dunce = "1.0.4"
|
||||
log = "0.4.20"
|
||||
env_logger = "0.11"
|
||||
serde_json = "1.0.108"
|
||||
slug = "0.1.5"
|
||||
derive_builder = "0.13.0"
|
||||
async-trait = "0.1.77"
|
||||
tracing = "0.1.40"
|
||||
tower-http = { version = "0.5.1", features = ["trace"] }
|
||||
378
rear/src/admin/domain.rs
Normal file
378
rear/src/admin/domain.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
pub use config::AdminModelConfig;
|
||||
pub use dto::AdminApp;
|
||||
pub use dto::AdminModel;
|
||||
pub use repository::{
|
||||
AdminRepository, LookupKey, RepoInfo, RepositoryContext, RepositoryInfo, RepositoryItem,
|
||||
RepositoryList, Widget,
|
||||
};
|
||||
|
||||
mod auth {
|
||||
|
||||
struct AdminUser {}
|
||||
|
||||
struct AdminRole {}
|
||||
|
||||
struct AdminGroup {}
|
||||
|
||||
struct AdminActionLog {}
|
||||
}
|
||||
|
||||
mod config {
|
||||
// user uses this configuration object to register another model.
|
||||
pub struct AdminModelConfig {
|
||||
pub name: String,
|
||||
pub app_key: String,
|
||||
}
|
||||
}
|
||||
|
||||
mod dto {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct AdminModel {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
|
||||
pub admin_url: String,
|
||||
pub view_only: bool,
|
||||
|
||||
pub add_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct AdminApp {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
|
||||
pub app_url: String,
|
||||
pub models: Vec<AdminModel>,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod repository {
|
||||
use super::dto::{AdminApp, AdminModel};
|
||||
use async_trait::async_trait;
|
||||
use derive_builder::Builder;
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, vec::IntoIter};
|
||||
|
||||
pub type RepositoryContext = AdminModel;
|
||||
|
||||
impl RepositoryContext {
|
||||
pub fn get_default_detail_url(&self, key: &str) -> Option<String> {
|
||||
Some(format!("{}/detail/{}", self.admin_url, key))
|
||||
}
|
||||
|
||||
pub fn get_default_change_url(&self, key: &str) -> Option<String> {
|
||||
Some(format!("{}/change/{}", self.admin_url, key))
|
||||
}
|
||||
|
||||
pub fn build_item(&self, key: &str, fields: Value) -> RepositoryItem {
|
||||
RepositoryItem {
|
||||
detail_url: self.get_default_detail_url(key),
|
||||
change_url: self.get_default_change_url(key),
|
||||
fields: fields,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This is a static configuration object.
|
||||
/// It might be changed in the future to have a dynamic counterpart.
|
||||
///
|
||||
/// ## Example:
|
||||
/// Creating a simple required and readonly text input field with a label:
|
||||
///
|
||||
/// ```ignore
|
||||
/// let my_field = Field::widget("/admin/widgets/input_text.jinja")
|
||||
/// .labelled("Username")
|
||||
/// .required()
|
||||
/// .readonly();
|
||||
/// ```
|
||||
#[derive(Debug, Serialize, Clone, Copy)]
|
||||
pub struct Widget {
|
||||
pub widget: &'static str,
|
||||
pub label: Option<&'static str>,
|
||||
#[serde(rename = "type")]
|
||||
pub field_type: &'static str,
|
||||
pub required: bool,
|
||||
pub readonly: bool,
|
||||
pub options: &'static [(&'static str, &'static str)],
|
||||
}
|
||||
|
||||
impl Widget {
|
||||
pub fn widget(widget: &'static str) -> Self {
|
||||
Widget {
|
||||
widget: widget,
|
||||
label: None,
|
||||
field_type: "text",
|
||||
required: false,
|
||||
readonly: false,
|
||||
options: &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default() -> Self {
|
||||
Self::widget("/admin/widgets/input_text.jinja")
|
||||
}
|
||||
|
||||
pub fn textarea() -> Self {
|
||||
Self::widget("/admin/widgets/input_textarea.jinja")
|
||||
}
|
||||
|
||||
pub fn required(mut self) -> Self {
|
||||
self.required = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn readonly(mut self) -> Self {
|
||||
self.readonly = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn labeled(mut self, label: &'static str) -> Self {
|
||||
self.label = Some(label);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn as_password(mut self) -> Self {
|
||||
self.field_type = "password";
|
||||
self
|
||||
}
|
||||
|
||||
pub fn as_hidden(mut self) -> Self {
|
||||
self.field_type = "hidden";
|
||||
self
|
||||
}
|
||||
|
||||
pub fn options(mut self, options: &'static [(&'static str, &'static str)]) -> Self {
|
||||
self.options = options;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Field {
|
||||
widget: String,
|
||||
label: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
field_type: String,
|
||||
readonly: bool,
|
||||
required: bool,
|
||||
options: Value,
|
||||
}
|
||||
|
||||
impl From<Widget> for Field {
|
||||
fn from(value: Widget) -> Self {
|
||||
Field {
|
||||
widget: value.widget.to_string(),
|
||||
label: value.label.map(|s| s.to_string()),
|
||||
field_type: value.field_type.to_string(),
|
||||
readonly: value.readonly,
|
||||
required: value.required,
|
||||
options: value
|
||||
.options
|
||||
.iter()
|
||||
.map(|(key, val)| (key.to_string(), serde_json::json!(val)))
|
||||
.collect::<serde_json::Map<String, Value>>()
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum LookupKey {
|
||||
Integer(usize),
|
||||
String(String),
|
||||
}
|
||||
|
||||
// Note that LookupKey auto converts to integer.
|
||||
impl From<String> for LookupKey {
|
||||
fn from(s: String) -> Self {
|
||||
if let Ok(int_key) = s.parse::<usize>() {
|
||||
LookupKey::Integer(int_key)
|
||||
} else {
|
||||
LookupKey::String(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for LookupKey {
|
||||
fn from(s: &str) -> Self {
|
||||
if let Ok(int_key) = s.parse::<usize>() {
|
||||
LookupKey::Integer(int_key)
|
||||
} else {
|
||||
LookupKey::String(s.to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for LookupKey {
|
||||
fn from(i: usize) -> Self {
|
||||
LookupKey::Integer(i)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LookupKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LookupKey::Integer(i) => write!(f, "{}", i),
|
||||
LookupKey::String(s) => write!(f, "{}", s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RepositoryItem {
|
||||
pub fields: Value,
|
||||
pub detail_url: Option<String>,
|
||||
pub change_url: Option<String>,
|
||||
}
|
||||
|
||||
pub enum RepositoryList {
|
||||
Empty,
|
||||
List {
|
||||
values: Vec<RepositoryItem>,
|
||||
},
|
||||
Page {
|
||||
values: Vec<RepositoryItem>,
|
||||
offset: usize,
|
||||
total: usize,
|
||||
},
|
||||
Stream {
|
||||
values: Vec<RepositoryItem>,
|
||||
next_index: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl IntoIterator for RepositoryList {
|
||||
type Item = RepositoryItem;
|
||||
type IntoIter = IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
match self {
|
||||
RepositoryList::Empty => vec![].into_iter(),
|
||||
RepositoryList::List { values } => values.into_iter(),
|
||||
RepositoryList::Page { values, .. } => values.into_iter(),
|
||||
RepositoryList::Stream { values, .. } => values.into_iter(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for RepositoryList {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match self {
|
||||
RepositoryList::Empty => serializer.serialize_unit(),
|
||||
RepositoryList::List { values }
|
||||
| RepositoryList::Page { values, .. }
|
||||
| RepositoryList::Stream { values, .. } => values.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Static initializer for RepositoryInfo.
|
||||
pub struct RepoInfo {
|
||||
pub name: &'static str,
|
||||
pub lookup_key: &'static str,
|
||||
pub display_list: &'static [&'static str],
|
||||
pub fields: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl RepoInfo {
|
||||
pub fn build(self) -> RepositoryInfo {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RepositoryInfo {
|
||||
name: String,
|
||||
lookup_key: String,
|
||||
display_list: Vec<String>,
|
||||
fields: Vec<(String, Field)>,
|
||||
}
|
||||
|
||||
impl RepositoryInfo {
|
||||
pub fn new(name: &str, lookup_key: &str) -> Self {
|
||||
RepositoryInfo {
|
||||
name: name.to_owned(),
|
||||
lookup_key: lookup_key.to_owned(),
|
||||
display_list: vec![],
|
||||
fields: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
// self mutating builder pattern
|
||||
pub fn display_list(mut self, display_list: &[&str]) -> Self {
|
||||
self.display_list = display_list.iter().map(|&e| e.to_string()).collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_widget<T: Into<Field>>(mut self, name: &str, item: T) -> Self {
|
||||
let field = item.into(); // Convert the input into a Field
|
||||
|
||||
// Find the index of the existing entry with the same name, if it exists
|
||||
let pos = self
|
||||
.fields
|
||||
.iter()
|
||||
.position(|(existing_name, _)| existing_name == name);
|
||||
|
||||
match pos {
|
||||
Some(index) => {
|
||||
self.fields[index].1 = field;
|
||||
}
|
||||
None => {
|
||||
self.fields.push((name.to_owned(), field));
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RepoInfo> for RepositoryInfo {
|
||||
fn from(repo_info: RepoInfo) -> Self {
|
||||
RepositoryInfo {
|
||||
name: repo_info.name.to_string(),
|
||||
lookup_key: repo_info.lookup_key.to_string(),
|
||||
display_list: repo_info
|
||||
.display_list
|
||||
.iter()
|
||||
.map(|&s| s.to_string())
|
||||
.collect(),
|
||||
fields: repo_info
|
||||
.fields
|
||||
.iter()
|
||||
.map(|x| (x.to_string(), Field::from(Widget::default())))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AdminRepository: Send + Sync {
|
||||
async fn info(&self, context: &RepositoryContext) -> RepositoryInfo;
|
||||
async fn list(&self, context: &RepositoryContext) -> RepositoryList;
|
||||
async fn get(&self, context: &RepositoryContext, id: LookupKey) -> Option<RepositoryItem>;
|
||||
async fn create(
|
||||
&mut self,
|
||||
context: &RepositoryContext,
|
||||
data: Value,
|
||||
) -> Option<RepositoryItem>;
|
||||
async fn update(
|
||||
&mut self,
|
||||
context: &RepositoryContext,
|
||||
id: LookupKey,
|
||||
data: Value,
|
||||
) -> Option<RepositoryItem>;
|
||||
async fn replace(
|
||||
&mut self,
|
||||
context: &RepositoryContext,
|
||||
id: LookupKey,
|
||||
data: Value,
|
||||
) -> Option<RepositoryItem>;
|
||||
async fn delete(&mut self, context: &RepositoryContext, id: LookupKey) -> Option<Value>;
|
||||
}
|
||||
}
|
||||
24
rear/src/admin/mod.rs
Normal file
24
rear/src/admin/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use axum::{routing::get, routing::post, Router};
|
||||
|
||||
pub mod domain;
|
||||
pub mod state;
|
||||
pub mod views;
|
||||
|
||||
pub fn route() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(views::index).post(views::index_action))
|
||||
.route("/app/:app", get(views::list_app))
|
||||
.route("/app/:app/model/:model", get(views::list_item_collection))
|
||||
.route(
|
||||
"/app/:app/model/:model/add",
|
||||
get(views::new_item).post(views::create_item),
|
||||
)
|
||||
.route(
|
||||
"/app/:app/model/:model/change/:id",
|
||||
get(views::change_item).patch(views::update_item),
|
||||
)
|
||||
.route(
|
||||
"/app/:app/model/:model/detail/:id",
|
||||
get(views::view_item_details),
|
||||
)
|
||||
}
|
||||
172
rear/src/admin/state.rs
Normal file
172
rear/src/admin/state.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::domain::{AdminApp, AdminModel, AdminModelConfig, AdminRepository};
|
||||
use crate::service::templates::Templates;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub trait AdminState {
|
||||
fn get_templates(&self) -> &Templates;
|
||||
fn get_registry(&self) -> SharedRegistry;
|
||||
}
|
||||
|
||||
pub type SharedRegistry = Arc<AdminRegistry>;
|
||||
|
||||
// main registry.
|
||||
pub struct AdminRegistry {
|
||||
base_path: String,
|
||||
apps: HashMap<String, internal::AdminApp>,
|
||||
models: HashMap<String, internal::AdminModel>,
|
||||
repositories: HashMap<String, Arc<Mutex<dyn AdminRepository>>>,
|
||||
}
|
||||
|
||||
impl AdminRegistry {
|
||||
pub fn new(base_path: &str) -> Self {
|
||||
AdminRegistry {
|
||||
base_path: base_path.to_owned(),
|
||||
apps: HashMap::new(),
|
||||
models: HashMap::new(),
|
||||
repositories: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_apps(&self) -> Vec<AdminApp> {
|
||||
self.apps
|
||||
.iter()
|
||||
.map(|(key, node)| self.get_app(key, node))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn get_app(&self, key: &str, node: &internal::AdminApp) -> AdminApp {
|
||||
let my_models = self.get_models(key);
|
||||
AdminApp {
|
||||
name: key.to_owned(),
|
||||
key: node.name.to_owned(),
|
||||
app_url: format!("/{}/app/{}", self.base_path, key.to_owned()),
|
||||
models: my_models,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_app(&mut self, name: &str) -> String {
|
||||
let key = self.get_key(name);
|
||||
self.apps.insert(
|
||||
key.to_owned(),
|
||||
internal::AdminApp {
|
||||
key: key.to_owned(),
|
||||
name: name.to_owned(),
|
||||
},
|
||||
);
|
||||
key
|
||||
}
|
||||
|
||||
fn get_key(&self, name: &str) -> String {
|
||||
slug::slugify(name)
|
||||
}
|
||||
|
||||
fn model_from_internal(&self, internal_model: &internal::AdminModel) -> AdminModel {
|
||||
let admin_url = format!(
|
||||
"/{}/app/{}/model/{}",
|
||||
self.base_path, internal_model.app_key, internal_model.model_key
|
||||
);
|
||||
AdminModel {
|
||||
key: internal_model.model_key.clone(),
|
||||
name: internal_model.name.clone(),
|
||||
view_only: false,
|
||||
add_url: Some(format!("{}/add", admin_url)),
|
||||
admin_url: admin_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_models(&self, app_key: &str) -> Vec<AdminModel> {
|
||||
self.models
|
||||
.iter()
|
||||
.filter(|(key, _)| key.starts_with(&format!("{}.", app_key)))
|
||||
.map(|(_, model)| self.model_from_internal(model))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_model(&self, app_key: &str, model_key: &str) -> Option<AdminModel> {
|
||||
let full_model_key = format!("{}.{}", app_key, model_key);
|
||||
let internal_model = self.models.get(&full_model_key)?;
|
||||
|
||||
// unfinished: we need to think about model_key vs. model_id vs. entry_id, as "name" is ambiguous.
|
||||
Some(self.model_from_internal(internal_model))
|
||||
}
|
||||
|
||||
fn register_model_config(&mut self, model: AdminModelConfig) -> Result<String, String> {
|
||||
let local_config = internal::AdminModel::from(model);
|
||||
if local_config.model_key.is_empty() {
|
||||
return Err("No model name".to_owned());
|
||||
}
|
||||
let local_config_name = format!("{}.{}", local_config.app_key, local_config.model_key);
|
||||
if self.models.contains_key(&local_config_name) {
|
||||
return Err(format!("Model {} already exists", local_config_name));
|
||||
}
|
||||
let full_model_key = local_config_name.clone();
|
||||
self.models.insert(local_config_name, local_config);
|
||||
Ok(full_model_key)
|
||||
}
|
||||
|
||||
pub fn register_model<R: AdminRepository + 'static>(
|
||||
&mut self,
|
||||
model: AdminModelConfig,
|
||||
repository: R,
|
||||
) -> Result<(), String> {
|
||||
let model_key = self.register_model_config(model)?;
|
||||
self.repositories
|
||||
.insert(model_key, Arc::new(Mutex::new(repository)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_repository(
|
||||
&self,
|
||||
app_key: &str,
|
||||
model_key: &str,
|
||||
) -> Result<Arc<Mutex<dyn AdminRepository>>, String> {
|
||||
let full_model_key = format!("{}.{}", app_key, model_key);
|
||||
if let Some(repo) = self.repositories.get(&full_model_key) {
|
||||
// Clone the Arc to return a reference to the repository
|
||||
return Ok(Arc::clone(repo));
|
||||
} else {
|
||||
return Err("Couldn't find repository".to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod internal {
|
||||
// how the registry saves data internally.
|
||||
|
||||
use super::super::domain::AdminModelConfig;
|
||||
#[derive(Clone)]
|
||||
pub struct AdminApp {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AdminModel {
|
||||
pub app_key: String,
|
||||
pub model_key: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl From<AdminModelConfig> for AdminModel {
|
||||
fn from(value: AdminModelConfig) -> Self {
|
||||
AdminModel {
|
||||
app_key: value.app_key,
|
||||
model_key: slug::slugify(value.name.clone()),
|
||||
name: value.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(&str, &str)> for AdminModel {
|
||||
fn from(value: (&str, &str)) -> Self {
|
||||
AdminModel {
|
||||
app_key: value.0.to_owned(),
|
||||
model_key: slug::slugify(value.1.to_owned()),
|
||||
name: value.1.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
319
rear/src/admin/views.rs
Normal file
319
rear/src/admin/views.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::Path;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Form;
|
||||
use axum::{extract::State, response::IntoResponse};
|
||||
use log::info;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::admin::domain::{AdminApp, AdminModel};
|
||||
use crate::admin::state;
|
||||
use crate::service::templates;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::domain::{LookupKey, RepositoryInfo, RepositoryItem, RepositoryList};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AdminRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AdminContext {
|
||||
pub base: Option<String>,
|
||||
pub language_code: Option<String>,
|
||||
pub language_bidi: Option<bool>,
|
||||
pub user: Option<String>, // Todo: user type
|
||||
pub admin_url: String,
|
||||
pub site_url: Option<String>,
|
||||
pub docsroot: Option<String>,
|
||||
pub messages: Vec<String>, // Todo: message type
|
||||
pub title: Option<String>,
|
||||
pub subtitle: Option<String>,
|
||||
pub content: String,
|
||||
|
||||
pub request: AdminRequest,
|
||||
pub available_apps: Vec<AdminApp>,
|
||||
pub item_model: Option<AdminModel>,
|
||||
pub item_info: Option<RepositoryInfo>,
|
||||
pub item_list: RepositoryList,
|
||||
pub item: Option<RepositoryItem>,
|
||||
}
|
||||
|
||||
impl Default for AdminContext {
|
||||
fn default() -> Self {
|
||||
AdminContext {
|
||||
base: None,
|
||||
language_code: Some("en-us".to_string()), // Default language code
|
||||
language_bidi: Some(false), // Default language bidi
|
||||
user: None, //UserType::default(), // Assuming UserType has a Default impl
|
||||
admin_url: "/admin".to_owned(),
|
||||
site_url: None,
|
||||
docsroot: None,
|
||||
messages: Vec::new(), // Empty vector for messages
|
||||
title: None,
|
||||
subtitle: None,
|
||||
content: String::new(), // Empty string for content
|
||||
available_apps: Vec::new(),
|
||||
request: AdminRequest {
|
||||
path: "".to_owned(),
|
||||
},
|
||||
item_model: None,
|
||||
item_info: None,
|
||||
item_list: RepositoryList::Empty,
|
||||
item: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn base_template(headers: &HeaderMap) -> Option<String> {
|
||||
let hx_request = headers.get("HX-Request").is_some();
|
||||
if hx_request {
|
||||
Some("admin/base_hx.jinja".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn index(
|
||||
templates: State<templates::Templates>,
|
||||
registry: State<Arc<state::AdminRegistry>>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
templates.render_html(
|
||||
"admin/index.html",
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Index Action is POST to the index site. We can anchor some general business code here.
|
||||
pub async fn index_action() -> impl IntoResponse {
|
||||
"There is your answer!".to_owned()
|
||||
}
|
||||
|
||||
pub async fn list_app(
|
||||
templates: State<templates::Templates>,
|
||||
Path(app_key): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
templates.render_html("admin/app_list.jinja", ())
|
||||
}
|
||||
|
||||
// List Items renders the entire list item page.
|
||||
pub async fn list_item_collection(
|
||||
templates: State<templates::Templates>,
|
||||
registry: State<Arc<state::AdminRegistry>>,
|
||||
headers: HeaderMap,
|
||||
Path((app_key, model_key)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
info!("list_item_collection {} for model {}", app_key, model_key);
|
||||
|
||||
let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) {
|
||||
let repo = repo.lock().await;
|
||||
let admin_model = registry
|
||||
.get_model(&app_key, &model_key)
|
||||
.expect("Admin Model not found?");
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
item_info: Some(repo.info(&admin_model).await),
|
||||
item_list: repo.list(&admin_model).await,
|
||||
item_model: Some(admin_model),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
templates.render_html("admin/items/item_list.jinja", context)
|
||||
}
|
||||
|
||||
// Items Action is a POST to an item list. By default these are actions, that work on a list of items as input.
|
||||
pub async fn item_collection_action(
|
||||
Path((app_key, model_key)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
"There is your answer!".to_owned()
|
||||
}
|
||||
|
||||
// Item Details shows one single dataset.
|
||||
pub async fn view_item_details(
|
||||
templates: State<templates::Templates>,
|
||||
registry: State<Arc<state::AdminRegistry>>,
|
||||
headers: HeaderMap,
|
||||
Path((app_key, model_key, id)): Path<(String, String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) {
|
||||
let repo = repo.lock().await;
|
||||
let admin_model = registry
|
||||
.get_model(&app_key, &model_key)
|
||||
.expect("Admin Model not found?");
|
||||
let key: LookupKey = id.into();
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
item_info: Some(repo.info(&admin_model).await),
|
||||
item_list: repo.list(&admin_model).await,
|
||||
item: repo.get(&admin_model, key).await,
|
||||
item_model: Some(admin_model),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
templates.render_html("admin/items/item_detail.jinja", context)
|
||||
}
|
||||
|
||||
pub async fn new_item(
|
||||
templates: State<templates::Templates>,
|
||||
registry: State<Arc<state::AdminRegistry>>,
|
||||
headers: HeaderMap,
|
||||
Path((app_key, model_key)): Path<(String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) {
|
||||
let repo = repo.lock().await;
|
||||
let admin_model = registry
|
||||
.get_model(&app_key, &model_key)
|
||||
.expect("Admin Model not found?");
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
item_info: Some(repo.info(&admin_model).await),
|
||||
item_list: repo.list(&admin_model).await,
|
||||
item_model: Some(admin_model),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
templates.render_html("admin/items/item_create.jinja", context)
|
||||
}
|
||||
|
||||
pub async fn create_item(
|
||||
templates: State<templates::Templates>,
|
||||
registry: State<Arc<state::AdminRegistry>>,
|
||||
headers: HeaderMap,
|
||||
Path((app_key, model_key)): Path<(String, String)>,
|
||||
Form(form): Form<Value>,
|
||||
) -> impl IntoResponse {
|
||||
let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) {
|
||||
let mut repo = repo.lock().await;
|
||||
let admin_model = registry
|
||||
.get_model(&app_key, &model_key)
|
||||
.expect("Admin Model not found?");
|
||||
|
||||
// create our item.
|
||||
let result = repo.create(&admin_model, form).await;
|
||||
|
||||
// TODO: refactor run over these views, way too much repetition.
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
item_info: Some(repo.info(&admin_model).await),
|
||||
item_list: repo.list(&admin_model).await,
|
||||
item_model: Some(admin_model),
|
||||
item: result,
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
templates.render_html("admin/items/item_create.jinja", context)
|
||||
}
|
||||
|
||||
/// Change is the GET version.
|
||||
pub async fn change_item(
|
||||
templates: State<templates::Templates>,
|
||||
registry: State<Arc<state::AdminRegistry>>,
|
||||
headers: HeaderMap,
|
||||
Path((app_key, model_key, id)): Path<(String, String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) {
|
||||
let repo = repo.lock().await;
|
||||
let admin_model = registry
|
||||
.get_model(&app_key, &model_key)
|
||||
.expect("Admin Model not found?");
|
||||
let key: LookupKey = id.into();
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
item_info: Some(repo.info(&admin_model).await),
|
||||
item_list: repo.list(&admin_model).await,
|
||||
item: repo.get(&admin_model, key).await,
|
||||
item_model: Some(admin_model),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
templates.render_html("admin/items/item_change.jinja", context)
|
||||
}
|
||||
|
||||
pub async fn update_item(
|
||||
templates: State<templates::Templates>,
|
||||
registry: State<Arc<state::AdminRegistry>>,
|
||||
headers: HeaderMap,
|
||||
Path((app_key, model_key, id)): Path<(String, String, String)>,
|
||||
Form(form): Form<Value>,
|
||||
) -> impl IntoResponse {
|
||||
let context = if let Ok(repo) = registry.get_repository(&app_key, &model_key) {
|
||||
let mut repo = repo.lock().await;
|
||||
let admin_model = registry
|
||||
.get_model(&app_key, &model_key)
|
||||
.expect("Admin Model not found?");
|
||||
let key: LookupKey = id.into();
|
||||
|
||||
let result = repo.update(&admin_model, key, form).await;
|
||||
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
item_info: Some(repo.info(&admin_model).await),
|
||||
item_list: repo.list(&admin_model).await,
|
||||
item: result,
|
||||
item_model: Some(admin_model),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
AdminContext {
|
||||
base: base_template(&headers),
|
||||
available_apps: registry.get_apps(),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
templates.render_html("admin/items/item_change.jinja", context)
|
||||
}
|
||||
|
||||
// Item Action allows running an action on one single dataset.
|
||||
pub async fn item_action(
|
||||
Path((app_key, model_key, model_id)): Path<(String, String, String)>,
|
||||
) -> impl IntoResponse {
|
||||
"There is your answer!".to_owned()
|
||||
}
|
||||
|
||||
pub async fn debug_view(Path(data): Path<String>) -> impl IntoResponse {
|
||||
println!("debug: {}", data);
|
||||
"Debug!".to_owned()
|
||||
}
|
||||
1
rear/src/auth/mod.rs
Normal file
1
rear/src/auth/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod models;
|
||||
8
rear/src/auth/models.rs
Normal file
8
rear/src/auth/models.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub struct User {
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub struct NewUser<'a> {
|
||||
pub username: &'a str,
|
||||
}
|
||||
3
rear/src/lib.rs
Normal file
3
rear/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod service;
|
||||
86
rear/src/service/error.rs
Normal file
86
rear/src/service/error.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::fmt;
|
||||
|
||||
pub struct Error {
|
||||
inner: anyhow::Error,
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
// Provide a builder method to set the status code
|
||||
pub fn with_status(mut self, status: StatusCode) -> Self {
|
||||
self.status = status;
|
||||
self
|
||||
}
|
||||
|
||||
// Provide a builder method to set the custom message
|
||||
pub fn with_message(mut self, message: String) -> Self {
|
||||
self.message = message;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Error {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inner: anyhow::Error::new(std::fmt::Error),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: String::from("An internal error occurred"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, format!("{}: {}", self.message, self.inner)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.message, self.inner)
|
||||
}
|
||||
}
|
||||
|
||||
// make it possible to use ? on anyhow::Error Results
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
Self {
|
||||
inner: err,
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: String::from("An unknown internal error occurred"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<minijinja::Error> for Error {
|
||||
fn from(err: minijinja::Error) -> Self {
|
||||
Self {
|
||||
inner: anyhow::Error::new(err),
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: String::from("A Templating Error occured"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::anyhow;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[test]
|
||||
fn test_error_with_status_and_message() {
|
||||
let error = Error::from(anyhow!("Test error"))
|
||||
.with_status(StatusCode::BAD_REQUEST)
|
||||
.with_message(String::from("A custom error message"));
|
||||
|
||||
assert_eq!(error.status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(error.message, "A custom error message");
|
||||
assert_eq!(format!("{}", error.inner), "Test error");
|
||||
}
|
||||
}
|
||||
73
rear/src/service/handlers.rs
Normal file
73
rear/src/service/handlers.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
/* Default handlers */
|
||||
use crate::service::templates::Templates;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{header, StatusCode, Uri},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
use std::marker::PhantomData;
|
||||
|
||||
// usage: .route_service("/static/*file", static_handler.into_service())
|
||||
pub async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
let mut path = uri.path().trim_start_matches('/').to_string();
|
||||
|
||||
if path.starts_with("static/") {
|
||||
path = path.replace("static/", "");
|
||||
}
|
||||
|
||||
StaticFile::get(path)
|
||||
}
|
||||
|
||||
// usage: .fallback(not_found_handler)
|
||||
pub async fn not_found_handler(
|
||||
State(templates): State<Templates>,
|
||||
) -> axum::response::Result<impl IntoResponse> {
|
||||
not_found(templates)
|
||||
}
|
||||
|
||||
fn not_found(templates: Templates) -> axum::response::Result<impl IntoResponse> {
|
||||
let body = templates.render_html("http404.html", ());
|
||||
Ok((StatusCode::NOT_FOUND, body))
|
||||
}
|
||||
|
||||
pub struct EmbeddedFile<E, T> {
|
||||
pub path: T,
|
||||
embed: PhantomData<E>,
|
||||
}
|
||||
|
||||
impl<E, T> EmbeddedFile<E, T> {
|
||||
pub fn get(path: T) -> Self {
|
||||
Self {
|
||||
path,
|
||||
embed: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E, T> IntoResponse for EmbeddedFile<E, T>
|
||||
where
|
||||
E: RustEmbed,
|
||||
T: AsRef<str>,
|
||||
{
|
||||
fn into_response(self) -> Response {
|
||||
let path: &str = self.path.as_ref();
|
||||
match E::get(path) {
|
||||
Some(content) => {
|
||||
let body = Body::from(content.data);
|
||||
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, mime.as_ref())
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "static"]
|
||||
struct StaticDir;
|
||||
type StaticFile<T> = EmbeddedFile<StaticDir, T>;
|
||||
3
rear/src/service/mod.rs
Normal file
3
rear/src/service/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
pub mod templates;
|
||||
166
rear/src/service/templates.rs
Normal file
166
rear/src/service/templates.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use crate::service::error::Error;
|
||||
use axum::response::Html;
|
||||
use minijinja::value::ValueKind;
|
||||
use minijinja::{path_loader, Environment, Value};
|
||||
use minijinja_autoreload::AutoReloader;
|
||||
use pulldown_cmark::Event;
|
||||
use serde::Deserializer;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Templates {
|
||||
pub reloader: Arc<minijinja_autoreload::AutoReloader>,
|
||||
}
|
||||
|
||||
impl Templates {
|
||||
pub fn initialize() -> Result<Self, String> {
|
||||
let reloader = AutoReloader::new(move |notifier| {
|
||||
let mut environment = Environment::new();
|
||||
let template_path = "templates";
|
||||
environment.set_loader(path_loader(&template_path));
|
||||
environment.add_filter("none", none);
|
||||
environment.add_filter("markdown", markdown);
|
||||
environment.add_filter("yesno", filter_yesno);
|
||||
environment.add_filter("table_keys", collect_unique_keys);
|
||||
environment.add_function("static", tpl_static);
|
||||
environment.add_function("url", tpl_url);
|
||||
environment.add_function("csrf_token", tpl_to_be_implemented);
|
||||
environment.add_function("translate", tpl_translate);
|
||||
|
||||
notifier.watch_path(template_path, true);
|
||||
Ok(environment)
|
||||
});
|
||||
Ok(Self {
|
||||
reloader: Arc::new(reloader),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn render_template<D: serde::Serialize>(
|
||||
&self,
|
||||
key: &str,
|
||||
data: D,
|
||||
) -> Result<String, Error> {
|
||||
let env = self.reloader.acquire_env()?;
|
||||
let template = env.get_template(key)?;
|
||||
let rendered = template.render(&data)?;
|
||||
Ok(rendered)
|
||||
}
|
||||
|
||||
pub fn render_html<D: serde::Serialize>(
|
||||
&self,
|
||||
key: &str,
|
||||
data: D,
|
||||
) -> Result<Html<String>, Error> {
|
||||
let result = self.render_template(key, data)?;
|
||||
Ok(Html(result))
|
||||
}
|
||||
}
|
||||
|
||||
fn markdown(value: String) -> Value {
|
||||
let mut options = pulldown_cmark::Options::empty();
|
||||
options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
|
||||
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
|
||||
options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
|
||||
options.insert(pulldown_cmark::Options::ENABLE_SMART_PUNCTUATION);
|
||||
let parser = pulldown_cmark::Parser::new_ext(&value, options);
|
||||
let parser: Box<dyn Iterator<Item = Event>> = Box::new(parser.into_iter());
|
||||
let mut html = String::new();
|
||||
pulldown_cmark::html::push_html(&mut html, parser);
|
||||
Value::from_safe_string(html)
|
||||
}
|
||||
|
||||
fn filter_yesno(value: String, yesno: String) -> String {
|
||||
"".into()
|
||||
}
|
||||
|
||||
fn tpl_url(value: String) -> Value {
|
||||
Value::from_safe_string(value.into())
|
||||
}
|
||||
|
||||
fn tpl_translate(value: String) -> Value {
|
||||
Value::from_safe_string(value.into())
|
||||
}
|
||||
|
||||
fn tpl_to_be_implemented(value: String) -> Value {
|
||||
Value::from_safe_string("<!-- To Be Implemented --/>".into())
|
||||
}
|
||||
|
||||
fn tpl_static(value: String) -> Value {
|
||||
Value::from_safe_string(format!("/static/{}", value))
|
||||
}
|
||||
|
||||
pub fn none(value: Value, other: Option<Value>) -> Value {
|
||||
if value.is_undefined() || value.is_none() {
|
||||
other.unwrap_or_else(|| Value::from(""))
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
fn tpl_table(values: Vec<Value>) -> Value {
|
||||
Value::from_safe_string(format!(
|
||||
"Output: {}",
|
||||
values.first().expect("No values found.").kind()
|
||||
))
|
||||
}
|
||||
|
||||
mod helper {
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub struct OrderedSet<T> {
|
||||
set: HashSet<T>,
|
||||
vec: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T: Eq + std::hash::Hash + Clone> OrderedSet<T> {
|
||||
pub fn new() -> Self {
|
||||
OrderedSet {
|
||||
set: HashSet::new(),
|
||||
vec: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, value: T) {
|
||||
if self.set.insert(value.clone()) {
|
||||
self.vec.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extend<I: IntoIterator<Item = T>>(&mut self, iter: I) {
|
||||
for item in iter {
|
||||
self.insert(item);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> std::slice::Iter<T> {
|
||||
self.vec.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoIterator for OrderedSet<T> {
|
||||
type Item = T;
|
||||
type IntoIter = std::vec::IntoIter<T>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.vec.into_iter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_unique_keys(values: Value) -> Value {
|
||||
use helper::OrderedSet;
|
||||
use serde::Deserialize;
|
||||
let mut unique_keys: OrderedSet<String> = OrderedSet::new();
|
||||
|
||||
if let Ok(vec) = Vec::<serde_json::Value>::deserialize(values) {
|
||||
for value in vec.iter() {
|
||||
if let Some(dict) = value.as_object() {
|
||||
let keys_vec: Vec<String> = dict.keys().map(|s| s.to_owned()).collect();
|
||||
unique_keys.extend(keys_vec);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let keys_list = unique_keys.into_iter().collect::<Vec<_>>();
|
||||
Value::from(keys_list)
|
||||
}
|
||||
Reference in New Issue
Block a user