Skip to main content

shared/models/
egg_repository.rs

1use crate::{
2    models::{InsertQueryBuilder, UpdateQueryBuilder},
3    prelude::*,
4};
5use compact_str::ToCompactString;
6use futures_util::StreamExt;
7use garde::Validate;
8use git2::FetchOptions;
9use serde::{Deserialize, Serialize};
10use sqlx::{Row, postgres::PgRow};
11use std::{
12    collections::BTreeMap,
13    path::PathBuf,
14    sync::{Arc, LazyLock},
15};
16use utoipa::ToSchema;
17
18#[derive(Serialize, Deserialize, Clone)]
19pub struct EggRepository {
20    pub uuid: uuid::Uuid,
21
22    pub name: compact_str::CompactString,
23    pub description: Option<compact_str::CompactString>,
24    pub git_repository: compact_str::CompactString,
25
26    pub last_synced: Option<chrono::NaiveDateTime>,
27    pub created: chrono::NaiveDateTime,
28
29    extension_data: super::ModelExtensionData,
30}
31
32impl BaseModel for EggRepository {
33    const NAME: &'static str = "egg_repository";
34
35    fn get_extension_list() -> &'static super::ModelExtensionList {
36        static EXTENSIONS: LazyLock<super::ModelExtensionList> =
37            LazyLock::new(|| std::sync::RwLock::new(Vec::new()));
38
39        &EXTENSIONS
40    }
41
42    fn get_extension_data(&self) -> &super::ModelExtensionData {
43        &self.extension_data
44    }
45
46    #[inline]
47    fn base_columns(prefix: Option<&str>) -> BTreeMap<&'static str, compact_str::CompactString> {
48        let prefix = prefix.unwrap_or_default();
49
50        BTreeMap::from([
51            (
52                "egg_repositories.uuid",
53                compact_str::format_compact!("{prefix}uuid"),
54            ),
55            (
56                "egg_repositories.name",
57                compact_str::format_compact!("{prefix}name"),
58            ),
59            (
60                "egg_repositories.description",
61                compact_str::format_compact!("{prefix}description"),
62            ),
63            (
64                "egg_repositories.git_repository",
65                compact_str::format_compact!("{prefix}git_repository"),
66            ),
67            (
68                "egg_repositories.last_synced",
69                compact_str::format_compact!("{prefix}last_synced"),
70            ),
71            (
72                "egg_repositories.created",
73                compact_str::format_compact!("{prefix}created"),
74            ),
75        ])
76    }
77
78    #[inline]
79    fn map(prefix: Option<&str>, row: &PgRow) -> Result<Self, crate::database::DatabaseError> {
80        let prefix = prefix.unwrap_or_default();
81
82        Ok(Self {
83            uuid: row.try_get(compact_str::format_compact!("{prefix}uuid").as_str())?,
84            name: row.try_get(compact_str::format_compact!("{prefix}name").as_str())?,
85            description: row
86                .try_get(compact_str::format_compact!("{prefix}description").as_str())?,
87            git_repository: row
88                .try_get(compact_str::format_compact!("{prefix}git_repository").as_str())?,
89            last_synced: row
90                .try_get(compact_str::format_compact!("{prefix}last_synced").as_str())?,
91            created: row.try_get(compact_str::format_compact!("{prefix}created").as_str())?,
92            extension_data: Self::map_extensions(prefix, row)?,
93        })
94    }
95}
96
97impl EggRepository {
98    pub async fn all_with_pagination(
99        database: &crate::database::Database,
100        page: i64,
101        per_page: i64,
102        search: Option<&str>,
103    ) -> Result<super::Pagination<Self>, crate::database::DatabaseError> {
104        let offset = (page - 1) * per_page;
105
106        let rows = sqlx::query(&format!(
107            r#"
108            SELECT {}, COUNT(*) OVER() AS total_count
109            FROM egg_repositories
110            WHERE ($1 IS NULL OR egg_repositories.name ILIKE '%' || $1 || '%')
111            ORDER BY egg_repositories.created
112            LIMIT $2 OFFSET $3
113            "#,
114            Self::columns_sql(None)
115        ))
116        .bind(search)
117        .bind(per_page)
118        .bind(offset)
119        .fetch_all(database.read())
120        .await?;
121
122        Ok(super::Pagination {
123            total: rows
124                .first()
125                .map_or(Ok(0), |row| row.try_get("total_count"))?,
126            per_page,
127            page,
128            data: rows
129                .into_iter()
130                .map(|row| Self::map(None, &row))
131                .try_collect_vec()?,
132        })
133    }
134
135    pub async fn sync(&self, database: &crate::database::Database) -> Result<usize, anyhow::Error> {
136        let git_repository = self.git_repository.clone();
137
138        let exported_eggs = tokio::task::spawn_blocking(
139            move || -> Result<Vec<(PathBuf, super::nest_egg::ExportedNestEgg)>, anyhow::Error> {
140                let mut exported_eggs = Vec::new();
141                let temp_dir = tempfile::tempdir()?;
142                let filesystem = crate::cap::CapFilesystem::new(temp_dir.path().to_path_buf())?;
143
144                let mut fetch_options = FetchOptions::new();
145                fetch_options.depth(1);
146                git2::build::RepoBuilder::new()
147                    .fetch_options(fetch_options)
148                    .clone(&git_repository, temp_dir.path())?;
149
150                let mut walker = filesystem.walk_dir(".")?;
151                while let Some(Ok((is_dir, entry))) = walker.next_entry() {
152                    if is_dir
153                        || !matches!(
154                            entry.extension().and_then(|s| s.to_str()),
155                            Some("json") | Some("yml") | Some("yaml")
156                        )
157                    {
158                        continue;
159                    }
160
161                    let metadata = match filesystem.metadata(&entry) {
162                        Ok(metadata) => metadata,
163                        Err(_) => continue,
164                    };
165
166                    // if any egg is larger than 1 MB, something went horribly wrong in development
167                    if !metadata.is_file() || metadata.len() > 1024 * 1024 {
168                        continue;
169                    }
170
171                    let file_content = match filesystem.read_to_string(&entry) {
172                        Ok(content) => content,
173                        Err(_) => continue,
174                    };
175                    let exported_egg: super::nest_egg::ExportedNestEgg =
176                        if entry.extension().and_then(|s| s.to_str()) == Some("json") {
177                            match serde_json::from_str(&file_content) {
178                                Ok(egg) => egg,
179                                Err(_) => continue,
180                            }
181                        } else {
182                            match serde_norway::from_str(&file_content) {
183                                Ok(egg) => egg,
184                                Err(_) => continue,
185                            }
186                        };
187
188                    exported_eggs.push((entry, exported_egg));
189                }
190
191                Ok(exported_eggs)
192            },
193        )
194        .await??;
195
196        super::egg_repository_egg::EggRepositoryEgg::delete_unused(
197            database,
198            self.uuid,
199            &exported_eggs
200                .iter()
201                .map(|(path, _)| path.to_string_lossy().to_compact_string())
202                .collect::<Vec<_>>(),
203        )
204        .await?;
205
206        let mut futures = Vec::new();
207        futures.reserve_exact(exported_eggs.len());
208
209        for (path, exported_egg) in exported_eggs.iter() {
210            futures.push(super::egg_repository_egg::EggRepositoryEgg::create(
211                database,
212                self.uuid,
213                path.to_string_lossy(),
214                &exported_egg.name,
215                exported_egg.description.as_deref(),
216                &exported_egg.author,
217                exported_egg,
218            ));
219        }
220
221        let mut results_stream = futures_util::stream::iter(futures).buffer_unordered(25);
222        while let Some(result) = results_stream.next().await {
223            result?;
224        }
225
226        sqlx::query(
227            r#"
228            UPDATE egg_repositories
229            SET last_synced = NOW()
230            WHERE egg_repositories.uuid = $1
231            "#,
232        )
233        .bind(self.uuid)
234        .execute(database.write())
235        .await?;
236
237        Ok(exported_eggs.len())
238    }
239}
240
241#[async_trait::async_trait]
242impl IntoAdminApiObject for EggRepository {
243    type AdminApiObject = AdminApiEggRepository;
244    type ExtraArgs<'a> = ();
245
246    async fn into_admin_api_object<'a>(
247        self,
248        state: &crate::State,
249        _args: Self::ExtraArgs<'a>,
250    ) -> Result<Self::AdminApiObject, crate::database::DatabaseError> {
251        let api_object = AdminApiEggRepository::init_hooks(&self, state).await?;
252
253        let api_object = finish_extendible!(
254            AdminApiEggRepository {
255                uuid: self.uuid,
256                name: self.name,
257                description: self.description,
258                git_repository: self.git_repository,
259                last_synced: self.last_synced.map(|dt| dt.and_utc()),
260                created: self.created.and_utc(),
261            },
262            api_object,
263            state
264        )?;
265
266        Ok(api_object)
267    }
268}
269
270#[derive(ToSchema, Deserialize, Validate)]
271pub struct CreateEggRepositoryOptions {
272    #[garde(length(chars, min = 1, max = 255))]
273    #[schema(min_length = 1, max_length = 255)]
274    pub name: compact_str::CompactString,
275    #[garde(length(max = 1024))]
276    #[schema(max_length = 1024)]
277    pub description: Option<compact_str::CompactString>,
278    #[garde(url)]
279    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
280    pub git_repository: compact_str::CompactString,
281}
282
283#[async_trait::async_trait]
284impl CreatableModel for EggRepository {
285    type CreateOptions<'a> = CreateEggRepositoryOptions;
286    type CreateResult = Self;
287
288    fn get_create_handlers() -> &'static LazyLock<CreateListenerList<Self>> {
289        static CREATE_LISTENERS: LazyLock<CreateListenerList<EggRepository>> =
290            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
291
292        &CREATE_LISTENERS
293    }
294
295    async fn create_with_transaction(
296        state: &crate::State,
297        mut options: Self::CreateOptions<'_>,
298        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
299    ) -> Result<Self::CreateResult, crate::database::DatabaseError> {
300        options.validate()?;
301
302        let mut query_builder = InsertQueryBuilder::new("egg_repositories");
303
304        Self::run_create_handlers(&mut options, &mut query_builder, state, transaction).await?;
305
306        query_builder
307            .set("name", &options.name)
308            .set("description", &options.description)
309            .set("git_repository", &options.git_repository);
310
311        let row = query_builder
312            .returning(&Self::columns_sql(None))
313            .fetch_one(&mut **transaction)
314            .await?;
315        let mut egg_repository = Self::map(None, &row)?;
316
317        Self::run_after_create_handlers(&mut egg_repository, &options, state, transaction).await?;
318
319        Ok(egg_repository)
320    }
321}
322
323#[derive(ToSchema, Serialize, Deserialize, Validate, Clone, Default)]
324pub struct UpdateEggRepositoryOptions {
325    #[garde(length(chars, min = 1, max = 255))]
326    #[schema(min_length = 1, max_length = 255)]
327    pub name: Option<compact_str::CompactString>,
328    #[garde(length(max = 1024))]
329    #[schema(max_length = 1024)]
330    #[serde(
331        default,
332        skip_serializing_if = "Option::is_none",
333        with = "::serde_with::rust::double_option"
334    )]
335    pub description: Option<Option<compact_str::CompactString>>,
336    #[garde(url)]
337    #[schema(example = "https://github.com/example/repo.git", format = "uri")]
338    pub git_repository: Option<compact_str::CompactString>,
339}
340
341#[async_trait::async_trait]
342impl UpdatableModel for EggRepository {
343    type UpdateOptions = UpdateEggRepositoryOptions;
344
345    fn get_update_handlers() -> &'static LazyLock<UpdateHandlerList<Self>> {
346        static UPDATE_LISTENERS: LazyLock<UpdateHandlerList<EggRepository>> =
347            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
348
349        &UPDATE_LISTENERS
350    }
351
352    async fn update_with_transaction(
353        &mut self,
354        state: &crate::State,
355        mut options: Self::UpdateOptions,
356        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
357    ) -> Result<(), crate::database::DatabaseError> {
358        options.validate()?;
359
360        let mut query_builder = UpdateQueryBuilder::new("egg_repositories");
361
362        self.run_update_handlers(&mut options, &mut query_builder, state, transaction)
363            .await?;
364
365        query_builder
366            .set("name", options.name.as_ref())
367            .set(
368                "description",
369                options.description.as_ref().map(|d| d.as_ref()),
370            )
371            .set("git_repository", options.git_repository.as_ref())
372            .where_eq("uuid", self.uuid);
373
374        query_builder.execute(&mut **transaction).await?;
375
376        if let Some(name) = options.name {
377            self.name = name;
378        }
379        if let Some(description) = options.description {
380            self.description = description;
381        }
382        if let Some(git_repository) = options.git_repository {
383            self.git_repository = git_repository;
384        }
385
386        self.run_after_update_handlers(state, transaction).await?;
387
388        Ok(())
389    }
390}
391
392#[async_trait::async_trait]
393impl ByUuid for EggRepository {
394    async fn by_uuid(
395        database: &crate::database::Database,
396        uuid: uuid::Uuid,
397    ) -> Result<Self, crate::database::DatabaseError> {
398        let row = sqlx::query(&format!(
399            r#"
400            SELECT {}
401            FROM egg_repositories
402            WHERE egg_repositories.uuid = $1
403            "#,
404            Self::columns_sql(None)
405        ))
406        .bind(uuid)
407        .fetch_one(database.read())
408        .await?;
409
410        Self::map(None, &row)
411    }
412
413    async fn by_uuid_with_transaction(
414        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
415        uuid: uuid::Uuid,
416    ) -> Result<Self, crate::database::DatabaseError> {
417        let row = sqlx::query(&format!(
418            r#"
419            SELECT {}
420            FROM egg_repositories
421            WHERE egg_repositories.uuid = $1
422            "#,
423            Self::columns_sql(None)
424        ))
425        .bind(uuid)
426        .fetch_one(&mut **transaction)
427        .await?;
428
429        Self::map(None, &row)
430    }
431}
432
433#[async_trait::async_trait]
434impl DeletableModel for EggRepository {
435    type DeleteOptions = ();
436
437    fn get_delete_handlers() -> &'static LazyLock<DeleteHandlerList<Self>> {
438        static DELETE_LISTENERS: LazyLock<DeleteHandlerList<EggRepository>> =
439            LazyLock::new(|| Arc::new(ModelHandlerList::default()));
440
441        &DELETE_LISTENERS
442    }
443
444    async fn delete_with_transaction(
445        &self,
446        state: &crate::State,
447        options: Self::DeleteOptions,
448        transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
449    ) -> Result<(), anyhow::Error> {
450        self.run_delete_handlers(&options, state, transaction)
451            .await?;
452
453        sqlx::query(
454            r#"
455            DELETE FROM egg_repositories
456            WHERE egg_repositories.uuid = $1
457            "#,
458        )
459        .bind(self.uuid)
460        .execute(&mut **transaction)
461        .await?;
462
463        self.run_after_delete_handlers(&options, state, transaction)
464            .await?;
465
466        Ok(())
467    }
468}
469
470#[schema_extension_derive::extendible]
471#[init_args(EggRepository, crate::State)]
472#[hook_args(crate::State)]
473#[derive(ToSchema, Serialize)]
474#[schema(title = "EggRepository")]
475pub struct AdminApiEggRepository {
476    pub uuid: uuid::Uuid,
477
478    pub name: compact_str::CompactString,
479    pub description: Option<compact_str::CompactString>,
480    pub git_repository: compact_str::CompactString,
481
482    pub last_synced: Option<chrono::DateTime<chrono::Utc>>,
483    pub created: chrono::DateTime<chrono::Utc>,
484}