From ea42c644f35d2cec2479403526e13ea04aa14fa9 Mon Sep 17 00:00:00 2001
From: aecsocket <43144841+aecsocket@users.noreply.github.com>
Date: Wed, 22 Jul 2026 20:14:57 +0100
Subject: [PATCH] show in/out schema
---
.../moderation/technical-review/rules.vue | 306 ++++++++++++++++-
apps/labrinth/src/routes/internal/mod.rs | 2 +
.../internal/moderation/tech_review/rules.rs | 309 +++++++++++++++---
.../moderation/tech_review/rules_scan.rs | 128 ++++++--
.../modules/labrinth/tech-review/internal.ts | 24 ++
.../api-client/src/modules/labrinth/types.ts | 28 ++
6 files changed, 707 insertions(+), 90 deletions(-)
diff --git a/apps/frontend/src/pages/moderation/technical-review/rules.vue b/apps/frontend/src/pages/moderation/technical-review/rules.vue
index d0447852c7..6cf23d476a 100644
--- a/apps/frontend/src/pages/moderation/technical-review/rules.vue
+++ b/apps/frontend/src/pages/moderation/technical-review/rules.vue
@@ -37,6 +37,37 @@
severity and/or hidden when it does.
+
+
+ Input and output schema
+
+
+
+ Loading schema…
+
+
+ {{ ruleSchemaError }}
+
+
+
+
+ Input (input)
+
+
{{ ruleInputSchemaText }}
+
+
+
+ Output
+
+
{{ ruleOutputSchemaText }}
+
+
+
+
@@ -233,7 +264,11 @@
description="Create a rule to transform matching issue traces."
/>
-
+
{{ rule.name }}
@@ -257,6 +292,112 @@
{{ rule.rule }}
+
+
+
+ Affected details ({{ rule.affected_details_count.toLocaleString() }})
+
+
+ No details are affected in the current revision.
+
+
+
+
+
+
+
+ {{ detail.project_name ?? detail.project_id }}
+
+
Unattached trace
+
+ ·
+
+ {{ detail.version_name ?? detail.version_number ?? detail.version_id }}
+
+
+
+
+
+ {{ detail.severity ?? detail.original_severity }}
+
+
+
+ Hidden
+
+ {{ detail.issue_type }}
+
+
+
+ {{ detail.jar }}
+
+
+ {{ detail.file_path }}
+
+
+
+
+
+ View
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -266,8 +407,10 @@
import { type Labrinth, SseParser } from '@modrinth/api-client'
import {
ArrowLeftIcon,
+ ChevronRightIcon,
EditIcon,
EyeOffIcon,
+ ExternalIcon,
LoaderCircleIcon,
PlayIcon,
PlusIcon,
@@ -275,6 +418,7 @@ import {
} from '@modrinth/assets'
import {
ButtonStyled,
+ Avatar,
ConfirmModal,
EmptyState,
injectModrinthClient,
@@ -335,13 +479,20 @@ const isLoading = ref(true)
const isSaving = ref(false)
const isScanning = ref(false)
const isTestingRule = ref(false)
+const isLoadingRuleSchema = ref(false)
const isRuleModalOpen = ref(false)
const loadFailed = ref(false)
+const ruleSchemaError = ref(null)
+const ruleSchema = ref(null)
const editingRuleId = ref(null)
const ruleToDelete = ref(null)
const ruleTestEffects = ref>([])
const ruleTestError = ref(null)
const scanProgress = ref(null)
+const expandedAffectedDetails = reactive(
+ new Map(),
+)
+const loadingAffectedRuleIds = reactive(new Set())
const form = reactive({
name: '',
rule: DEFAULT_RULE,
@@ -358,6 +509,12 @@ onMounted(async () => {
})
const modalTitle = computed(() => (editingRuleId.value === null ? 'Create rule' : 'Edit rule'))
+const ruleInputSchemaText = computed(() =>
+ ruleSchema.value ? formatRuleSchema(ruleSchema.value.input, ruleSchema.value.components) : '',
+)
+const ruleOutputSchemaText = computed(() =>
+ ruleSchema.value ? formatRuleSchema(ruleSchema.value.output, ruleSchema.value.components) : '',
+)
const previewExamples = computed(() =>
TEST_TRACES.map((original, index) => {
const effect = ruleTestEffects.value[index] ?? null
@@ -399,6 +556,92 @@ function getSeverityBadgeColor(severity: Labrinth.TechReview.Internal.DelphiSeve
}
}
+function isSchema(value: unknown): value is Labrinth.TechReview.Internal.DelphiRuleSchema {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function formatRuleSchema(
+ schema: Labrinth.TechReview.Internal.DelphiRuleSchema,
+ components: Record,
+ depth = 0,
+ visitedReferences = new Set(),
+): string {
+ if (typeof schema.$ref === 'string') {
+ const name = decodeURIComponent(schema.$ref.split('/').at(-1) ?? '')
+ if (visitedReferences.has(name)) return name || 'unknown'
+
+ const referencedSchema = components[name]
+ if (!referencedSchema) return name || 'unknown'
+
+ const visited = new Set(visitedReferences)
+ visited.add(name)
+ return formatRuleSchema(referencedSchema, components, depth, visited)
+ }
+
+ const resolved = schema
+ const alternatives = [resolved.oneOf, resolved.anyOf].find(Array.isArray)
+ if (alternatives) {
+ return alternatives
+ .filter(isSchema)
+ .map((alternative) => formatRuleSchema(alternative, components, depth, visitedReferences))
+ .join(' | ')
+ }
+
+ if (Array.isArray(resolved.enum)) {
+ return resolved.enum.map((value) => JSON.stringify(value)).join(' | ')
+ }
+
+ const declaredTypes = Array.isArray(resolved.type)
+ ? resolved.type.filter((type): type is string => typeof type === 'string')
+ : typeof resolved.type === 'string'
+ ? [resolved.type]
+ : []
+ const nullable = resolved.nullable === true || declaredTypes.includes('null')
+ const type = declaredTypes.find((value) => value !== 'null')
+ let formatted: string
+
+ if (type === 'object' || isSchema(resolved.properties) || resolved.additionalProperties) {
+ const properties = isSchema(resolved.properties) ? resolved.properties : {}
+ const required = new Set(
+ Array.isArray(resolved.required)
+ ? resolved.required.filter((name): name is string => typeof name === 'string')
+ : [],
+ )
+ const indentation = ' '.repeat(depth)
+ const childIndentation = ' '.repeat(depth + 1)
+ const lines = Object.entries(properties)
+ .filter((entry): entry is [string, Labrinth.TechReview.Internal.DelphiRuleSchema] =>
+ isSchema(entry[1]),
+ )
+ .map(
+ ([name, property]) =>
+ `${childIndentation}${JSON.stringify(name)}${required.has(name) ? '' : '?'}: ${formatRuleSchema(property, components, depth + 1, visitedReferences)}`,
+ )
+
+ if (isSchema(resolved.additionalProperties)) {
+ lines.push(
+ `${childIndentation}[key: string]: ${formatRuleSchema(resolved.additionalProperties, components, depth + 1, visitedReferences)}`,
+ )
+ } else if (resolved.additionalProperties === true) {
+ lines.push(`${childIndentation}[key: string]: unknown`)
+ }
+
+ formatted = lines.length === 0 ? '{}' : `{\n${lines.join(',\n')}\n${indentation}}`
+ } else if (type === 'array') {
+ formatted = isSchema(resolved.items)
+ ? `Array<${formatRuleSchema(resolved.items, components, depth, visitedReferences)}>`
+ : 'unknown[]'
+ } else if (type === 'integer' || type === 'number') {
+ formatted = 'number'
+ } else if (type === 'boolean' || type === 'string' || type === 'null') {
+ formatted = type
+ } else {
+ formatted = 'unknown'
+ }
+
+ return nullable && formatted !== 'null' ? `${formatted} | null` : formatted
+}
+
function onRuleEditorInit(editor: Ace.Editor) {
ruleEditorInstance.value = editor
editor.session.setUseWrapMode(true)
@@ -460,6 +703,7 @@ async function loadRules() {
loadFailed.value = false
try {
rules.value = await client.labrinth.tech_review_internal.getRules()
+ expandedAffectedDetails.clear()
} catch (error) {
console.error('Failed to load Delphi rules', error)
loadFailed.value = true
@@ -468,6 +712,64 @@ async function loadRules() {
}
}
+async function loadRuleSchema() {
+ if (ruleSchema.value || isLoadingRuleSchema.value) return
+
+ isLoadingRuleSchema.value = true
+ ruleSchemaError.value = null
+ try {
+ ruleSchema.value = await client.labrinth.tech_review_internal.getRuleSchema()
+ } catch (error) {
+ console.error('Failed to load Delphi rule schema', error)
+ ruleSchemaError.value = 'The rule input and output schema could not be loaded.'
+ } finally {
+ isLoadingRuleSchema.value = false
+ }
+}
+
+function getVisibleRuleDetails(
+ rule: Labrinth.TechReview.Internal.DelphiRule,
+): Labrinth.TechReview.Internal.DelphiRuleAffectedDetail[] {
+ return expandedAffectedDetails.get(rule.id) ?? rule.affected_details
+}
+
+function getAffectedDetailLink(
+ detail: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail,
+): string {
+ return `/moderation/technical-review/${detail.project_id}?detail=${encodeURIComponent(detail.detail_id)}`
+}
+
+function getProjectLink(detail: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail): string {
+ return `/project/${detail.project_id}`
+}
+
+function getVersionLink(detail: Labrinth.TechReview.Internal.DelphiRuleAffectedDetail): string {
+ return `/project/${detail.project_id}/version/${detail.version_id}`
+}
+
+async function toggleAffectedDetails(rule: Labrinth.TechReview.Internal.DelphiRule) {
+ if (expandedAffectedDetails.has(rule.id)) {
+ expandedAffectedDetails.delete(rule.id)
+ return
+ }
+ if (loadingAffectedRuleIds.has(rule.id)) return
+
+ loadingAffectedRuleIds.add(rule.id)
+ try {
+ const details = await client.labrinth.tech_review_internal.getRuleAffectedDetails(rule.id)
+ expandedAffectedDetails.set(rule.id, details)
+ } catch (error) {
+ console.error('Failed to load details affected by Delphi rule', error)
+ addNotification({
+ type: 'error',
+ title: 'Failed to load affected details',
+ text: 'The complete list of affected details could not be loaded.',
+ })
+ } finally {
+ loadingAffectedRuleIds.delete(rule.id)
+ }
+}
+
function openCreateModal() {
if (isScanning.value) return
editingRuleId.value = null
@@ -476,6 +778,7 @@ function openCreateModal() {
isRuleModalOpen.value = true
ruleModal.value?.show()
nextTick(() => ruleEditorInstance.value?.resize(true))
+ void loadRuleSchema()
void testRule()
}
@@ -487,6 +790,7 @@ function openEditModal(rule: Labrinth.TechReview.Internal.DelphiRule) {
isRuleModalOpen.value = true
ruleModal.value?.show()
nextTick(() => ruleEditorInstance.value?.resize(true))
+ void loadRuleSchema()
void testRule()
}
diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs
index d54c86c742..6dc3734377 100644
--- a/apps/labrinth/src/routes/internal/mod.rs
+++ b/apps/labrinth/src/routes/internal/mod.rs
@@ -117,9 +117,11 @@ pub fn config(cfg: &mut web::ServiceConfig) {
moderation::tech_review::global::get_global_issue_detail,
moderation::tech_review::rules::get_rules,
moderation::tech_review::rules::test_rule,
+ moderation::tech_review::rules::get_rule_affected_details,
moderation::tech_review::rules::create_rule,
moderation::tech_review::rules::update_rule,
moderation::tech_review::rules::delete_rule,
+ moderation::tech_review::rules_scan::get_rule_schema,
moderation::tech_review::rules_scan::scan_rules,
moderation::tech_review::get_project_report,
moderation::tech_review::submit_report,
diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs
index 9a0a55d23c..00c2f4a91a 100644
--- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs
+++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules.rs
@@ -5,13 +5,23 @@ use chrono::{DateTime, Utc};
use eyre::eyre;
use serde::{Deserialize, Serialize};
+use super::rules_scan::{
+ RuleArtifact, RuleInput, RuleScan, RuleScope, RuleTrace,
+};
use crate::{
auth::check_is_moderator_from_headers,
database::{
- PgPool, ReadOnlyPgPool, models::delphi_report_item::DelphiSeverity,
+ PgPool, ReadOnlyPgPool,
+ models::{
+ DBProjectId, DBVersionId, DelphiReportIssueDetailsId,
+ DelphiReportIssueId, delphi_report_item::DelphiSeverity,
+ },
redis::RedisPool,
},
- models::pats::Scopes,
+ models::{
+ ids::{ProjectId, VersionId},
+ pats::Scopes,
+ },
queue::session::AuthQueue,
routes::ApiError,
util::error::Context,
@@ -24,6 +34,7 @@ const MAX_RULE_TEST_TRACES: usize = 10;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(get_rules)
.service(test_rule)
+ .service(get_rule_affected_details)
.service(create_rule)
.service(update_rule)
.service(delete_rule);
@@ -39,6 +50,27 @@ pub struct DelphiRule {
pub updated_at: DateTime,
pub created_by: Option,
pub updated_by: Option,
+ pub affected_details_count: i64,
+ pub affected_details: Vec,
+}
+
+#[derive(Debug, Serialize, utoipa::ToSchema)]
+pub struct DelphiRuleAffectedDetail {
+ pub detail_id: DelphiReportIssueDetailsId,
+ pub issue_id: DelphiReportIssueId,
+ pub project_id: Option,
+ pub project_name: Option,
+ pub project_icon_url: Option,
+ pub version_id: Option,
+ pub version_name: Option,
+ pub version_number: Option,
+ pub issue_type: String,
+ pub key: String,
+ pub jar: Option,
+ pub file_path: String,
+ pub original_severity: DelphiSeverity,
+ pub severity: Option,
+ pub hidden: bool,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
@@ -77,33 +109,6 @@ pub struct DelphiRuleEffect {
pub hidden: bool,
}
-#[derive(Serialize)]
-struct TestRuleInput<'a> {
- schema_version: u32,
- trace: &'a TestDelphiRuleTrace,
- scan: TestRuleScan,
- artifact: TestRuleArtifact,
- scope: TestRuleScope,
-}
-
-#[derive(Serialize)]
-struct TestRuleScan {
- delphi_version: i32,
-}
-
-#[derive(Serialize)]
-struct TestRuleArtifact {
- size: u32,
- hashes: BTreeMap,
-}
-
-#[derive(Serialize)]
-struct TestRuleScope {
- project_id: String,
- version_id: String,
- file_id: String,
-}
-
struct ValidatedRule {
name: String,
rule: String,
@@ -203,22 +208,29 @@ pub async fn test_rule(
Ok(web::Json(TestDelphiRuleResponse { effects }))
}
-fn test_rule_input(trace: &TestDelphiRuleTrace) -> TestRuleInput<'_> {
- TestRuleInput {
+fn test_rule_input(trace: &TestDelphiRuleTrace) -> RuleInput {
+ RuleInput {
schema_version: 1,
- trace,
- scan: TestRuleScan { delphi_version: 17 },
- artifact: TestRuleArtifact {
- size: 412_892,
+ trace: RuleTrace {
+ key: trace.key.clone(),
+ issue_type: trace.issue_type.clone(),
+ severity: trace.severity,
+ jar: trace.jar.clone(),
+ file_path: trace.file_path.clone(),
+ data: trace.data.clone(),
+ },
+ scan: RuleScan { delphi_version: 17 },
+ artifact: RuleArtifact {
+ size: Some(412_892),
hashes: BTreeMap::from([
("sha1".to_string(), "0123456789abcdef".to_string()),
("sha512".to_string(), "fedcba9876543210".to_string()),
]),
},
- scope: TestRuleScope {
- project_id: "example-project".to_string(),
- version_id: "example-version".to_string(),
- file_id: "example-file".to_string(),
+ scope: RuleScope {
+ project_id: Some("example-project".to_string()),
+ version_id: Some("example-version".to_string()),
+ file_id: Some("example-file".to_string()),
},
}
}
@@ -250,27 +262,81 @@ pub async fn get_rules(
let rules = sqlx::query!(
r#"
SELECT
- id,
- name,
- rule,
- revision,
- created_at,
- updated_at,
- created_by,
- updated_by
- FROM delphi_rules
- WHERE NOT delete_on_next_revision
- ORDER BY id
+ delphi_rule.id,
+ delphi_rule.name,
+ delphi_rule.rule,
+ delphi_rule.revision,
+ delphi_rule.created_at,
+ delphi_rule.updated_at,
+ delphi_rule.created_by,
+ delphi_rule.updated_by,
+ COALESCE(preview.affected_details_count, 0)
+ AS "affected_details_count!",
+ preview.detail_id AS "detail_id?: DelphiReportIssueDetailsId",
+ preview.issue_id AS "issue_id?: DelphiReportIssueId",
+ preview.project_id AS "project_id?: DBProjectId",
+ preview.project_name AS "project_name?",
+ preview.project_icon_url AS "project_icon_url?",
+ preview.version_id AS "version_id?: DBVersionId",
+ preview.version_name AS "version_name?",
+ preview.version_number AS "version_number?",
+ preview.issue_type AS "issue_type?",
+ preview.key AS "key?",
+ preview.jar AS "jar?",
+ preview.file_path AS "file_path?",
+ preview.original_severity AS "original_severity?: DelphiSeverity",
+ preview.severity AS "effect_severity?: DelphiSeverity",
+ preview.hidden AS "hidden?"
+ FROM delphi_rules delphi_rule
+ LEFT JOIN LATERAL (
+ SELECT
+ effect.detail_id,
+ detail.issue_id,
+ version.mod_id AS project_id,
+ project.name AS project_name,
+ project.icon_url AS project_icon_url,
+ version.id AS version_id,
+ version.name AS version_name,
+ version.version_number,
+ issue.issue_type,
+ detail.key,
+ detail.jar,
+ detail.file_path,
+ detail.severity AS original_severity,
+ effect.severity,
+ effect.hidden,
+ COUNT(*) OVER () AS affected_details_count
+ FROM delphi_rule_effects effect
+ INNER JOIN delphi_rule_revisions published
+ ON published.revision = effect.revision
+ INNER JOIN delphi_report_issue_details detail
+ ON detail.id = effect.detail_id
+ INNER JOIN delphi_report_issues issue
+ ON issue.id = detail.issue_id
+ INNER JOIN delphi_reports report
+ ON report.id = issue.report_id
+ LEFT JOIN files file ON file.id = report.file_id
+ LEFT JOIN versions version ON version.id = file.version_id
+ LEFT JOIN mods project ON project.id = version.mod_id
+ WHERE effect.rule_id = delphi_rule.id
+ ORDER BY effect.detail_id DESC
+ LIMIT 3
+ ) preview ON TRUE
+ WHERE NOT delphi_rule.delete_on_next_revision
+ ORDER BY delphi_rule.id, preview.detail_id DESC
"#,
)
.fetch_all(&***ro_pool)
.await
.wrap_internal_err("failed to fetch delphi rules")?;
- Ok(web::Json(
- rules
- .into_iter()
- .map(|rule| DelphiRule {
+ let mut response = Vec::::new();
+ for rule in rules {
+ if response
+ .last()
+ .is_none_or(|existing| existing.id != rule.id)
+ {
+ response.push(DelphiRule {
id: rule.id,
name: rule.name,
rule: rule.rule,
@@ -279,6 +345,137 @@ pub async fn get_rules(
updated_at: rule.updated_at,
created_by: rule.created_by,
updated_by: rule.updated_by,
+ affected_details_count: rule.affected_details_count,
+ affected_details: Vec::new(),
+ });
+ }
+
+ if let (
+ Some(detail_id),
+ Some(issue_id),
+ Some(issue_type),
+ Some(key),
+ Some(file_path),
+ Some(original_severity),
+ Some(hidden),
+ ) = (
+ rule.detail_id,
+ rule.issue_id,
+ rule.issue_type,
+ rule.key,
+ rule.file_path,
+ rule.original_severity,
+ rule.hidden,
+ ) {
+ response
+ .last_mut()
+ .expect("a delphi rule was inserted above")
+ .affected_details
+ .push(DelphiRuleAffectedDetail {
+ detail_id,
+ issue_id,
+ project_id: rule.project_id.map(ProjectId::from),
+ project_name: rule.project_name,
+ project_icon_url: rule.project_icon_url,
+ version_id: rule.version_id.map(VersionId::from),
+ version_name: rule.version_name,
+ version_number: rule.version_number,
+ issue_type,
+ key,
+ jar: rule.jar,
+ file_path,
+ original_severity,
+ severity: rule.effect_severity,
+ hidden,
+ });
+ }
+ }
+
+ Ok(web::Json(response))
+}
+
+/// List all details affected by a Delphi rule in the published revision.
+#[utoipa::path(
+ context_path = "/moderation/tech-review",
+ tag = "moderation",
+ security(("bearer_auth" = [])),
+ responses((status = OK, body = Vec))
+)]
+#[get("/rules/{id}/effects")]
+pub async fn get_rule_affected_details(
+ req: HttpRequest,
+ pool: web::Data,
+ ro_pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+ path: web::Path<(i64,)>,
+) -> Result>, ApiError> {
+ check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await?;
+ let (rule_id,) = path.into_inner();
+
+ let details = sqlx::query!(
+ r#"
+ SELECT
+ effect.detail_id AS "detail_id!: DelphiReportIssueDetailsId",
+ detail.issue_id AS "issue_id!: DelphiReportIssueId",
+ version.mod_id AS "project_id?: DBProjectId",
+ project.name AS "project_name?",
+ project.icon_url AS "project_icon_url?",
+ version.id AS "version_id?: DBVersionId",
+ version.name AS "version_name?",
+ version.version_number AS "version_number?",
+ issue.issue_type,
+ detail.key,
+ detail.jar,
+ detail.file_path,
+ detail.severity AS "original_severity!: DelphiSeverity",
+ effect.severity AS "effect_severity: DelphiSeverity",
+ effect.hidden
+ FROM delphi_rule_effects effect
+ INNER JOIN delphi_rule_revisions published
+ ON published.revision = effect.revision
+ INNER JOIN delphi_report_issue_details detail
+ ON detail.id = effect.detail_id
+ INNER JOIN delphi_report_issues issue ON issue.id = detail.issue_id
+ INNER JOIN delphi_reports report ON report.id = issue.report_id
+ LEFT JOIN files file ON file.id = report.file_id
+ LEFT JOIN versions version ON version.id = file.version_id
+ LEFT JOIN mods project ON project.id = version.mod_id
+ WHERE effect.rule_id = $1
+ ORDER BY effect.detail_id DESC
+ "#,
+ rule_id,
+ )
+ .fetch_all(&***ro_pool)
+ .await
+ .wrap_internal_err("failed to fetch details affected by delphi rule")?;
+
+ Ok(web::Json(
+ details
+ .into_iter()
+ .map(|detail| DelphiRuleAffectedDetail {
+ detail_id: detail.detail_id,
+ issue_id: detail.issue_id,
+ project_id: detail.project_id.map(ProjectId::from),
+ project_name: detail.project_name,
+ project_icon_url: detail.project_icon_url,
+ version_id: detail.version_id.map(VersionId::from),
+ version_name: detail.version_name,
+ version_number: detail.version_number,
+ issue_type: detail.issue_type,
+ key: detail.key,
+ jar: detail.jar,
+ file_path: detail.file_path,
+ original_severity: detail.original_severity,
+ severity: detail.effect_severity,
+ hidden: detail.hidden,
})
.collect(),
))
@@ -354,6 +551,8 @@ pub async fn create_rule(
updated_at: rule.updated_at,
created_by: rule.created_by,
updated_by: rule.updated_by,
+ affected_details_count: 0,
+ affected_details: Vec::new(),
}))
}
@@ -427,6 +626,8 @@ pub async fn update_rule(
updated_at: rule.updated_at,
created_by: rule.created_by,
updated_by: rule.updated_by,
+ affected_details_count: 0,
+ affected_details: Vec::new(),
}))
}
diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs
index 4ee7f4c595..e6895558ac 100644
--- a/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs
+++ b/apps/labrinth/src/routes/internal/moderation/tech_review/rules_scan.rs
@@ -1,6 +1,6 @@
use std::collections::{BTreeMap, HashMap};
-use actix_web::{HttpRequest, HttpResponse, post, web};
+use actix_web::{HttpRequest, HttpResponse, get, post, web};
use ariadne::ids::base62_impl::to_base62;
use bytes::Bytes;
use eyre::{Context as _, Result, eyre};
@@ -9,6 +9,7 @@ use serde::Serialize;
use sqlx::types::Json;
use tokio::sync::mpsc;
use tokio_stream::wrappers::UnboundedReceiverStream;
+use utoipa::{PartialSchema, ToSchema};
use super::rules::DelphiRuleEffect;
use crate::{
@@ -26,7 +27,7 @@ const RULE_SCAN_LOCK_ID: i64 = 0x6465_6c70_6869_7275;
const PROGRESS_INTERVAL: usize = 50;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
- cfg.service(scan_rules);
+ cfg.service(get_rule_schema).service(scan_rules);
}
#[derive(Serialize)]
@@ -43,41 +44,41 @@ struct RuleScanErrorEvent<'a> {
message: &'a str,
}
-#[derive(Serialize)]
-struct RuleInput {
- schema_version: u32,
- trace: RuleTrace,
- scan: RuleScan,
- artifact: RuleArtifact,
- scope: RuleScope,
+#[derive(Serialize, utoipa::ToSchema)]
+pub(super) struct RuleInput {
+ pub(super) schema_version: u32,
+ pub(super) trace: RuleTrace,
+ pub(super) scan: RuleScan,
+ pub(super) artifact: RuleArtifact,
+ pub(super) scope: RuleScope,
}
-#[derive(Serialize)]
-struct RuleTrace {
- key: String,
- issue_type: String,
- severity: DelphiSeverity,
- jar: Option,
- file_path: String,
- data: HashMap,
+#[derive(Serialize, utoipa::ToSchema)]
+pub(super) struct RuleTrace {
+ pub(super) key: String,
+ pub(super) issue_type: String,
+ pub(super) severity: DelphiSeverity,
+ pub(super) jar: Option,
+ pub(super) file_path: String,
+ pub(super) data: HashMap,
}
-#[derive(Serialize)]
-struct RuleScan {
- delphi_version: i32,
+#[derive(Serialize, utoipa::ToSchema)]
+pub(super) struct RuleScan {
+ pub(super) delphi_version: i32,
}
-#[derive(Serialize)]
-struct RuleArtifact {
- size: Option,
- hashes: BTreeMap,
+#[derive(Serialize, utoipa::ToSchema)]
+pub(super) struct RuleArtifact {
+ pub(super) size: Option,
+ pub(super) hashes: BTreeMap,
}
-#[derive(Serialize)]
-struct RuleScope {
- project_id: Option,
- version_id: Option,
- file_id: Option,
+#[derive(Serialize, utoipa::ToSchema)]
+pub(super) struct RuleScope {
+ pub(super) project_id: Option,
+ pub(super) version_id: Option,
+ pub(super) file_id: Option,
}
struct CompiledRule {
@@ -98,6 +99,62 @@ struct ScanSummary {
effects: usize,
}
+#[derive(Serialize, utoipa::ToSchema)]
+pub struct DelphiRuleSchemaResponse {
+ pub input: serde_json::Value,
+ pub output: serde_json::Value,
+ pub components: BTreeMap,
+}
+
+/// Get the schemas for the CEL input and output values.
+#[utoipa::path(
+ context_path = "/moderation/tech-review",
+ tag = "moderation",
+ security(("bearer_auth" = [])),
+ responses((status = OK, body = DelphiRuleSchemaResponse))
+)]
+#[get("/rules/schema")]
+pub async fn get_rule_schema(
+ req: HttpRequest,
+ pool: web::Data,
+ redis: web::Data,
+ session_queue: web::Data,
+) -> Result, ApiError> {
+ check_is_moderator_from_headers(
+ &req,
+ &**pool,
+ &redis,
+ &session_queue,
+ Scopes::PROJECT_READ,
+ )
+ .await?;
+
+ let mut schemas = Vec::new();
+ ::schemas(&mut schemas);
+