Skip to main content

yt/fields/
schema.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use crate::api::{Client, Projection};
5use crate::error::Result;
6use crate::fields::{same, usage};
7
8/// A project's custom fields as yt needs them for validation and typed writes.
9#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
10pub struct ProjectSchema {
11    pub project: String,
12    pub fields: Vec<SchemaField>,
13}
14
15#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
16pub struct SchemaField {
17    pub name: String,
18    pub aliases: Vec<String>,
19    /// `fieldType.id` without its multiplicity suffix: `enum`, `state`, `user`, `date and time`, …
20    pub type_id: String,
21    pub multi: bool,
22    pub can_be_empty: bool,
23    pub has_default: bool,
24    /// Allowed values: non-archived bundle values, or the logins a user field accepts.
25    pub values: Vec<String>,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Kind {
30    Bundle,
31    State,
32    User,
33    Date,
34    DateTime,
35    Period,
36    Integer,
37    Float,
38    String,
39    Text,
40    Other,
41}
42
43impl SchemaField {
44    /// A field the schema does not know, written only through commands (`--no-validate`).
45    pub fn unknown(name: &str) -> Self {
46        Self {
47            name: name.to_string(),
48            aliases: Vec::new(),
49            type_id: String::new(),
50            multi: false,
51            can_be_empty: true,
52            has_default: true,
53            values: Vec::new(),
54        }
55    }
56
57    pub fn kind(&self) -> Kind {
58        match self.type_id.as_str() {
59            "enum" | "version" | "build" | "ownedField" | "group" => Kind::Bundle,
60            "state" => Kind::State,
61            "user" => Kind::User,
62            "date" => Kind::Date,
63            "date and time" => Kind::DateTime,
64            "period" => Kind::Period,
65            "integer" => Kind::Integer,
66            "float" => Kind::Float,
67            "string" => Kind::String,
68            "text" => Kind::Text,
69            _ => Kind::Other,
70        }
71    }
72
73    /// The `$type` of this field's value in a typed REST write.
74    pub fn write_type(&self) -> Option<&'static str> {
75        let (single, multi) = match self.type_id.as_str() {
76            "enum" => (
77                "SingleEnumIssueCustomField",
78                Some("MultiEnumIssueCustomField"),
79            ),
80            "state" => ("StateIssueCustomField", None),
81            "user" => (
82                "SingleUserIssueCustomField",
83                Some("MultiUserIssueCustomField"),
84            ),
85            "version" => (
86                "SingleVersionIssueCustomField",
87                Some("MultiVersionIssueCustomField"),
88            ),
89            "build" => (
90                "SingleBuildIssueCustomField",
91                Some("MultiBuildIssueCustomField"),
92            ),
93            "ownedField" => (
94                "SingleOwnedIssueCustomField",
95                Some("MultiOwnedIssueCustomField"),
96            ),
97            "group" => (
98                "SingleGroupIssueCustomField",
99                Some("MultiGroupIssueCustomField"),
100            ),
101            "date" | "date and time" => ("DateIssueCustomField", None),
102            "period" => ("PeriodIssueCustomField", None),
103            "integer" | "float" | "string" => ("SimpleIssueCustomField", None),
104            "text" => ("TextIssueCustomField", None),
105            _ => return None,
106        };
107        if self.multi { multi } else { Some(single) }
108    }
109
110    pub fn matches(&self, name: &str) -> bool {
111        same(&self.name, name) || self.aliases.iter().any(|alias| same(alias, name))
112    }
113}
114
115impl ProjectSchema {
116    pub fn field(&self, name: &str) -> Option<&SchemaField> {
117        self.fields.iter().find(|field| field.matches(name))
118    }
119}
120
121const PROJECT_FIELD: &str = "canBeEmpty,defaultValues(name,login),field(name,aliases,fieldType(id,isMultiValue)),bundle(values(name,login,archived),aggregatedUsers(login))";
122
123#[derive(Deserialize)]
124#[serde(rename_all = "camelCase")]
125struct RawProjectField {
126    #[serde(default)]
127    can_be_empty: bool,
128    #[serde(default)]
129    default_values: Value,
130    field: RawField,
131    bundle: Option<RawBundle>,
132}
133
134impl Projection for RawProjectField {
135    const FIELDS: &'static str = PROJECT_FIELD;
136}
137
138#[derive(Deserialize)]
139#[serde(rename_all = "camelCase")]
140struct RawField {
141    name: String,
142    #[serde(default)]
143    aliases: Value,
144    field_type: RawFieldType,
145}
146
147#[derive(Deserialize)]
148#[serde(rename_all = "camelCase")]
149struct RawFieldType {
150    id: String,
151    #[serde(default)]
152    is_multi_value: bool,
153}
154
155#[derive(Deserialize)]
156#[serde(rename_all = "camelCase")]
157struct RawBundle {
158    #[serde(default)]
159    values: Vec<RawValue>,
160    #[serde(default)]
161    aggregated_users: Vec<RawUser>,
162}
163
164#[derive(Deserialize)]
165struct RawValue {
166    name: Option<String>,
167    login: Option<String>,
168    #[serde(default)]
169    archived: bool,
170}
171
172#[derive(Deserialize)]
173struct RawUser {
174    login: String,
175}
176
177#[derive(Deserialize)]
178#[serde(rename_all = "camelCase")]
179struct IssueFields {
180    #[serde(default)]
181    custom_fields: Vec<IssueField>,
182}
183
184#[derive(Deserialize)]
185#[serde(rename_all = "camelCase")]
186struct IssueField {
187    project_custom_field: Option<RawProjectField>,
188}
189
190impl Projection for IssueFields {
191    const FIELDS: &'static str = "customFields(projectCustomField(canBeEmpty,defaultValues(name,login),field(name,aliases,fieldType(id,isMultiValue)),bundle(values(name,login,archived),aggregatedUsers(login))))";
192}
193
194impl From<RawProjectField> for SchemaField {
195    fn from(raw: RawProjectField) -> Self {
196        let id = raw.field.field_type.id;
197        let type_id = id
198            .split_once('[')
199            .map_or(id.as_str(), |(base, _)| base)
200            .to_string();
201        let values = match raw.bundle {
202            Some(bundle) if !bundle.aggregated_users.is_empty() => bundle
203                .aggregated_users
204                .into_iter()
205                .map(|user| user.login)
206                .collect(),
207            Some(bundle) => bundle
208                .values
209                .into_iter()
210                .filter(|value| !value.archived)
211                .filter_map(|value| {
212                    if type_id == "user" {
213                        value.login.or(value.name)
214                    } else {
215                        value.name.or(value.login)
216                    }
217                })
218                .collect(),
219            None => Vec::new(),
220        };
221        let aliases = match raw.field.aliases {
222            Value::String(text) => text
223                .split(',')
224                .map(str::trim)
225                .filter(|alias| !alias.is_empty())
226                .map(str::to_string)
227                .collect(),
228            Value::Array(items) => items
229                .into_iter()
230                .filter_map(|item| item.as_str().map(str::to_string))
231                .collect(),
232            _ => Vec::new(),
233        };
234        let has_default = match raw.default_values {
235            Value::Array(items) => !items.is_empty(),
236            Value::Null => false,
237            _ => true,
238        };
239        Self {
240            name: raw.field.name,
241            aliases,
242            type_id,
243            multi: raw.field.field_type.is_multi_value,
244            can_be_empty: raw.can_be_empty,
245            has_default,
246            values,
247        }
248    }
249}
250
251/// Reads a project's fields: from the project, or from one of its issues when the project lists none
252/// (some instances hide project settings from non-admins).
253pub async fn fetch(client: &Client, project: &str) -> Result<ProjectSchema> {
254    check_short_name(project)?;
255    let fields: Vec<RawProjectField> = client
256        .get(
257            &format!("admin/projects/{project}/customFields"),
258            &[("$top", "500")],
259        )
260        .await?;
261    let fields: Vec<SchemaField> = if fields.is_empty() {
262        let query = format!("project: {{{project}}}");
263        let issues: Vec<IssueFields> = client
264            .get("issues", &[("query", query.as_str()), ("$top", "1")])
265            .await?;
266        issues
267            .into_iter()
268            .next()
269            .map(|issue| {
270                issue
271                    .custom_fields
272                    .into_iter()
273                    .filter_map(|field| field.project_custom_field)
274                    .map(SchemaField::from)
275                    .collect()
276            })
277            .unwrap_or_default()
278    } else {
279        fields.into_iter().map(SchemaField::from).collect()
280    };
281    Ok(ProjectSchema {
282        project: project.to_string(),
283        fields,
284    })
285}
286
287/// Project short names go into URL paths; anything else is rejected.
288pub fn check_short_name(project: &str) -> Result<()> {
289    if !project.is_empty() && project.chars().all(|c| c.is_alphanumeric() || c == '_') {
290        Ok(())
291    } else {
292        Err(usage(format!("'{project}' is not a project short name")))
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use serde_json::json;
299    use wiremock::matchers::{method, path, query_param};
300    use wiremock::{Mock, MockServer, ResponseTemplate};
301
302    use super::*;
303    use crate::api::test_client;
304
305    fn project_fields() -> serde_json::Value {
306        json!([
307            {"canBeEmpty": false, "defaultValues": [{"name": "Normal"}],
308             "field": {"name": "Priority", "aliases": "prio, p", "fieldType": {"id": "enum[1]", "isMultiValue": false}},
309             "bundle": {"values": [{"name": "Normal", "archived": false}, {"name": "Major", "archived": false}, {"name": "Old", "archived": true}]}},
310            {"canBeEmpty": true, "defaultValues": [],
311             "field": {"name": "Assignee", "aliases": "", "fieldType": {"id": "user[1]", "isMultiValue": false}},
312             "bundle": {"aggregatedUsers": [{"login": "jane"}, {"login": "bob"}]}},
313            {"canBeEmpty": true, "defaultValues": [],
314             "field": {"name": "Fix versions", "fieldType": {"id": "version[*]", "isMultiValue": true}},
315             "bundle": {"values": [{"name": "1.0", "archived": false}]}},
316            {"canBeEmpty": true,
317             "field": {"name": "Due Date", "fieldType": {"id": "date", "isMultiValue": false}}}
318        ])
319    }
320
321    fn expected() -> Vec<SchemaField> {
322        vec![
323            SchemaField {
324                name: "Priority".into(),
325                aliases: vec!["prio".into(), "p".into()],
326                type_id: "enum".into(),
327                multi: false,
328                can_be_empty: false,
329                has_default: true,
330                values: vec!["Normal".into(), "Major".into()],
331            },
332            SchemaField {
333                name: "Assignee".into(),
334                aliases: vec![],
335                type_id: "user".into(),
336                multi: false,
337                can_be_empty: true,
338                has_default: false,
339                values: vec!["jane".into(), "bob".into()],
340            },
341            SchemaField {
342                name: "Fix versions".into(),
343                aliases: vec![],
344                type_id: "version".into(),
345                multi: true,
346                can_be_empty: true,
347                has_default: false,
348                values: vec!["1.0".into()],
349            },
350            SchemaField {
351                name: "Due Date".into(),
352                aliases: vec![],
353                type_id: "date".into(),
354                multi: false,
355                can_be_empty: true,
356                has_default: false,
357                values: vec![],
358            },
359        ]
360    }
361
362    #[tokio::test]
363    async fn the_schema_comes_from_the_project() {
364        let server = MockServer::start().await;
365        Mock::given(method("GET"))
366            .and(path("/api/admin/projects/WEB/customFields"))
367            .respond_with(ResponseTemplate::new(200).set_body_json(project_fields()))
368            .expect(1)
369            .mount(&server)
370            .await;
371        let schema = fetch(&test_client(&server.uri()), "WEB").await.unwrap();
372        assert_eq!(schema.project, "WEB");
373        assert_eq!(schema.fields, expected());
374    }
375
376    #[tokio::test]
377    async fn an_empty_project_list_falls_back_to_an_issue() {
378        let server = MockServer::start().await;
379        Mock::given(method("GET"))
380            .and(path("/api/admin/projects/WEB/customFields"))
381            .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
382            .mount(&server)
383            .await;
384        let issue_fields: Vec<serde_json::Value> = project_fields()
385            .as_array()
386            .unwrap()
387            .iter()
388            .map(|field| json!({"projectCustomField": field}))
389            .collect();
390        Mock::given(method("GET"))
391            .and(path("/api/issues"))
392            .and(query_param("query", "project: {WEB}"))
393            .respond_with(
394                ResponseTemplate::new(200).set_body_json(json!([{"customFields": issue_fields}])),
395            )
396            .expect(1)
397            .mount(&server)
398            .await;
399        let schema = fetch(&test_client(&server.uri()), "WEB").await.unwrap();
400        assert_eq!(schema.fields, expected());
401    }
402
403    #[test]
404    fn kinds_and_write_types_follow_the_field_type() {
405        let mut field = expected().remove(2);
406        assert_eq!(field.kind(), Kind::Bundle);
407        assert_eq!(field.write_type(), Some("MultiVersionIssueCustomField"));
408        field.multi = false;
409        assert_eq!(field.write_type(), Some("SingleVersionIssueCustomField"));
410        for (type_id, kind, write_type) in [
411            ("state", Kind::State, Some("StateIssueCustomField")),
412            (
413                "date and time",
414                Kind::DateTime,
415                Some("DateIssueCustomField"),
416            ),
417            ("period", Kind::Period, Some("PeriodIssueCustomField")),
418            ("integer", Kind::Integer, Some("SimpleIssueCustomField")),
419            ("text", Kind::Text, Some("TextIssueCustomField")),
420            ("", Kind::Other, None),
421        ] {
422            field.type_id = type_id.into();
423            assert_eq!(
424                (field.kind(), field.write_type()),
425                (kind, write_type),
426                "{type_id}"
427            );
428        }
429    }
430
431    #[test]
432    fn fields_are_found_by_name_or_alias() {
433        let schema = ProjectSchema {
434            project: "WEB".into(),
435            fields: expected(),
436        };
437        assert_eq!(
438            schema.field("PRIO").map(|field| field.name.as_str()),
439            Some("Priority")
440        );
441        assert_eq!(
442            schema
443                .field("fix VERSIONS")
444                .map(|field| field.name.as_str()),
445            Some("Fix versions")
446        );
447        assert!(schema.field("Type").is_none());
448    }
449
450    #[test]
451    fn only_short_names_are_accepted_in_paths() {
452        assert!(check_short_name("WEB_2").is_ok());
453        assert!(check_short_name("A/B").is_err());
454        assert!(check_short_name("").is_err());
455    }
456}