Repository: CBIIT/ccdi-federation-api
Pull request #139: feat: experimental sample-diagnosis endpoint
Author: login: e-t-k
Created: 2025-03-25T19:10:17Z | Merged: 2025-05-13T17:51:18Z

Discussion: https://github.com/CBIIT/ccdi-federation-api/discussions/131 **Improved disease querying**
Projects:
https://github.com/orgs/CBIIT/projects/19/views/1?pane=issue&itemId=102925127
https://github.com/orgs/CBIIT/projects/19/views/1?pane=issue&itemId=102925260

**PR Close Date:** Not yet determined

**Main feature**
Adds `sample-diagnosis` endpoint with a `search` parameter. This parameter performs case-insensitive string subset searching on the `diagnosis` field. 
Examples:
[http://localhost:8000/sample-diagnosis?search=Random Diagnosis](http://localhost:8000/sample-diagnosis?search=Random%20Diagnosis) : Returns all samples with a diagnosis.
http://localhost:8000/sample-diagnosis?search=X : Returns all samples with diagnosis `Random Diagnosis X`
[http://localhost:8000/sample-diagnosis?search=SIS y](http://localhost:8000/sample-diagnosis?search=SIS%20y) Returns all samples with diagnosis `Random Diagnosis Y`

Other `sample` parameters are also usable with `sample-diagnosis`. 
Example: http://localhost:8000/sample-diagnosis?tissue_type=Tumor&per_page=2

**Other changes**
To support the main feature, this pull also implements the following:
- Adds missing `sample?diagnosis=xyz` search parameter. Previously, searching by diagnosis would return all samples
regardless of query value. Example: http://localhost:8000/sample?diagnosis=Random%20Diagnosis%20A
- Updates the sample's diagnosis randomizer. Previously every sample had the same disease, "Random Diagnosis";
now it is "Random Diagnosis X" where X is a letter or digit. This enables diagnosis searching to return meaningful results.
- Adds missing `sample/by/diagnosis/count` endpoint. Example: http://localhost:8000/sample/by/diagnosis/count

**Known Caveats**
- `sample-diagnosis` does not yet appear in the swagger.yml file
- the `search` parameter is not limited to the diagnosis character set described in #131 and will allow searches on all sorts of characters rather than returning an error. Example: [http://localhost:8000/sample-diagnosis?search=/Example🙂%^](http://localhost:8000/sample-diagnosis?search=/Example%F0%9F%99%82%^)
- This implementation also adds the `search` parameter to the `sample` endpoint itself, but testers can choose not to use it.
 
Before submitting this PR, please make sure:

- [x] You have added a few sentences describing the PR here.
- [x] You have added yourself or the appropriate individual as the assignee.
- [X] You have added the relevant groups/individuals to the reviewers.
- [ ] Your commit messages conform to the [Conventional
      Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard. *(I missed one!)*
- [x] You have updated the README or other documentation to account for these
      changes (when appropriate). *(Not applicable for this change)*
- [x] You have added a line describing the change in the `CHANGELOG.md` under
      `[Unreleased]`.




Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2f70397..caa51c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,12 +8,23 @@ Versioning](https://semver.org/spec/v2.0.0.html).
 
 ## [Unreleased]
 
+### Added
+
+- Adds an experimental `sample-diagnosis` endpoint with `search` filter to enable case-insensitive substring searching of diagnoses. 
+([discussion](https://github.com/CBIIT/ccdi-federation-api/discussions/131), [#139](https://github.com/CBIIT/ccdi-federation-api/issues/139)).
+
+### Changed
+
+- Updates the sample's diagnosis randomizer ([#139](https://github.com/CBIIT/ccdi-federation-api/issues/139)).
+
 ### Fixed
 
 - Corrects the duplicated `Black or African Amercian` race description
   ([#129](https://github.com/CBIIT/ccdi-federation-api/issues/129)).
 - Updates the depositions field to be nullable
   ([#140](https://github.com/CBIIT/ccdi-federation-api/pull/140)).
+- Adds missing `sample?diagnosis=xyz` search parameter. ([#139](https://github.com/CBIIT/ccdi-federation-api/issues/139)).
+- Adds missing `sample/by/diagnosis/count` endpoint. ([#139](https://github.com/CBIIT/ccdi-federation-api/issues/139)).
 
 ## [v1.1.0] — 12-17-2024
 
diff --git a/crates/ccdi-models/src/sample/metadata.rs b/crates/ccdi-models/src/sample/metadata.rs
index c424c12..b50a2e6 100644
--- a/crates/ccdi-models/src/sample/metadata.rs
+++ b/crates/ccdi-models/src/sample/metadata.rs
@@ -726,7 +726,10 @@ impl Metadata {
                 None,
             )]),
             diagnosis: Some(field::unowned::sample::Diagnosis::new(
-                Diagnosis::from(String::from("Random Diagnosis")),
+                Diagnosis::from(format!(
+                    "Random Diagnosis {}",
+                    rng.sample(Alphanumeric).to_ascii_uppercase() as char,
+                )),
                 None,
                 None,
                 None,
diff --git a/crates/ccdi-openapi/src/api.rs b/crates/ccdi-openapi/src/api.rs
index 6c7eec4..ae644f3 100644
--- a/crates/ccdi-openapi/src/api.rs
+++ b/crates/ccdi-openapi/src/api.rs
@@ -85,6 +85,10 @@ use utoipa::openapi;
             name = "Info",
             description = "Information about the API implementation itself."
         ),
+        (
+            name = "Experimental",
+            description = "Endpoints and features in an experimental phase."
+        ),
     ),
     paths(
         // Subject routes.
@@ -120,6 +124,9 @@ use utoipa::openapi;
 
         // Information.
         server::routes::info::info_index,
+
+        // Experimental.
+        server::routes::sample_diagnosis::sample_diagnosis_index,
     ),
     components(schemas(
         // Harmonized common metadata elements.
diff --git a/crates/ccdi-server/src/filter.rs b/crates/ccdi-server/src/filter.rs
index 100e9fb..2553901 100644
--- a/crates/ccdi-server/src/filter.rs
+++ b/crates/ccdi-server/src/filter.rs
@@ -8,6 +8,7 @@ use models::Entity;
 
 pub mod file;
 pub mod sample;
+pub mod sample_diagnosis;
 pub mod subject;
 
 /// A trait that defines a method for filtering by metadata values.
diff --git a/crates/ccdi-server/src/filter/sample.rs b/crates/ccdi-server/src/filter/sample.rs
index 174cecc..1643c75 100644
--- a/crates/ccdi-server/src/filter/sample.rs
+++ b/crates/ccdi-server/src/filter/sample.rs
@@ -24,6 +24,7 @@ impl FilterMetadataField<Sample, FilterSampleParams> for Vec<Sample> {
             "age_at_collection" => params.age_at_collection.as_ref(),
             "tumor_tissue_morphology" => params.tumor_tissue_morphology.as_ref(),
             "depositions" => params.depositions.as_ref(),
+            "diagnosis" => params.diagnosis.as_ref(),
             _ => unreachable!("unhandled sample metadata field: {field}"),
         };
 
@@ -104,6 +105,10 @@ impl FilterMetadataField<Sample, FilterSampleParams> for Vec<Sample> {
                                 })
                                 .collect::<Vec<String>>()
                         }),
+                    "diagnosis" => sample
+                        .metadata()
+                        .and_then(|metadata| metadata.diagnosis())
+                        .map(|diagnosis| vec![diagnosis.to_string()]),
                     _ => unreachable!("unhandled sample metadata field: {field}"),
                 };
 
diff --git a/crates/ccdi-server/src/filter/sample_diagnosis.rs b/crates/ccdi-server/src/filter/sample_diagnosis.rs
new file mode 100644
index 0000000..c7b9e6e
--- /dev/null
+++ b/crates/ccdi-server/src/filter/sample_diagnosis.rs
@@ -0,0 +1,159 @@
+//! Filter parameters for [`Sample`]s on the sample-diagnosis endpoint.
+
+use ccdi_models as models;
+
+use models::metadata::common::deposition::Accession;
+use models::Sample;
+
+use crate::filter::FilterMetadataField;
+use crate::params::filter::SampleDiagnosis as FilterSampleDiagnosisParams;
+
+impl FilterMetadataField<Sample, FilterSampleDiagnosisParams> for Vec<Sample> {
+    fn filter_metadata_field(
+        self,
+        field: String,
+        params: &FilterSampleDiagnosisParams,
+    ) -> Vec<Sample> {
+        let parameter = match field.as_str() {
+            "anatomical_sites" => params.anatomical_sites.as_ref(),
+            "disease_phase" => params.disease_phase.as_ref(),
+            "library_selection_method" => params.library_selection_method.as_ref(),
+            "library_strategy" => params.library_strategy.as_ref(),
+            "library_source_material" => params.library_source_material.as_ref(),
+            "preservation_method" => params.preservation_method.as_ref(),
+            "specimen_molecular_analyte_type" => params.specimen_molecular_analyte_type.as_ref(),
+            "tissue_type" => params.tissue_type.as_ref(),
+            "tumor_classification" => params.tumor_classification.as_ref(),
+            "age_at_diagnosis" => params.age_at_diagnosis.as_ref(),
+            "age_at_collection" => params.age_at_collection.as_ref(),
+            "tumor_tissue_morphology" => params.tumor_tissue_morphology.as_ref(),
+            "depositions" => params.depositions.as_ref(),
+            "diagnosis" => params.diagnosis.as_ref(),
+            "search" => params.search.as_ref(),
+            _ => unreachable!("unhandled sample metadata field: {field}"),
+        };
+
+        let query = match parameter {
+            Some(query) => query,
+            // If the parameter has no value, just return the original list of
+            // samples, as the user does not want to filter based on that.
+            None => return self,
+        };
+
+        self.into_iter()
+            .filter(|sample| {
+                // Search field matches by substring rather than exact match.
+                if field.as_str() == "search" {
+                    if let Some(metadata) = sample.metadata() {
+                        if let Some(diagnosis) = metadata.diagnosis() {
+                            // Only return the entry if the query is a substring
+                            // of the diagnosis, ignoring case.
+                            // Matching on to_lowercase is an approximation and will not cover
+                            // all unicode characters.
+                            let diagnosis_lower = diagnosis.to_string().to_lowercase();
+                            let query_lower = query.to_string().to_lowercase();
+                            return diagnosis_lower.to_string().contains(&query_lower);
+                        }
+
+                        // If the metadata doesn't have a diagnosis, the entry
+                        // should not be included.
+                        return false;
+                    }
+
+                    // If no metadata is included, this entry should not be
+                    // included.
+                    false
+                } else {
+                    // All other "non-search" fields require an exact case-sensitive match.
+                    let values: Option<Vec<String>> = match field.as_str() {
+                        "anatomical_sites" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.anatomical_sites())
+                            .map(|sites| {
+                                sites
+                                    .iter()
+                                    .map(|site| site.to_string())
+                                    .collect::<Vec<_>>()
+                            }),
+                        "disease_phase" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.disease_phase())
+                            .map(|disease_phase| vec![disease_phase.to_string()]),
+                        "library_selection_method" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.library_selection_method())
+                            .map(|library_selection_method| {
+                                vec![library_selection_method.to_string()]
+                            }),
+                        "library_strategy" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.library_strategy())
+                            .map(|library_strategy| vec![library_strategy.to_string()]),
+                        "library_source_material" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.library_source_material())
+                            .map(|library_source_material| {
+                                vec![library_source_material.to_string()]
+                            }),
+                        "preservation_method" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.preservation_method())
+                            .map(|preservation_method| vec![preservation_method.to_string()]),
+                        "specimen_molecular_analyte_type" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.specimen_molecular_analyte_type())
+                            .map(|specimen_molecular_analyte_type| {
+                                vec![specimen_molecular_analyte_type.to_string()]
+                            }),
+                        "tissue_type" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.tissue_type())
+                            .map(|tissue_type| vec![tissue_type.to_string()]),
+                        "tumor_classification" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.tumor_classification())
+                            .map(|tumor_classification| vec![tumor_classification.to_string()]),
+                        "age_at_diagnosis" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.age_at_diagnosis())
+                            .map(|age_at_diagnosis| vec![age_at_diagnosis.to_string()]),
+                        "age_at_collection" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.age_at_collection())
+                            .map(|age_at_collection| vec![age_at_collection.to_string()]),
+                        "tumor_tissue_morphology" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.tumor_tissue_morphology())
+                            .map(|tumor_tissue_morphology| {
+                                vec![tumor_tissue_morphology.to_string()]
+                            }),
+                        "depositions" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.common().depositions())
+                            .map(|deposition| {
+                                deposition
+                                    .iter()
+                                    .cloned()
+                                    .map(|accession| match accession {
+                                        Accession::dbGaP(accession) => accession.to_string(),
+                                    })
+                                    .collect::<Vec<String>>()
+                            }),
+                        "diagnosis" => sample
+                            .metadata()
+                            .and_then(|metadata| metadata.diagnosis())
+                            .map(|diagnosis| vec![diagnosis.to_string()]),
+                        _ => unreachable!("unhandled sample metadata field: {field}"),
+                    };
+
+                    match values {
+                        Some(values) => values.into_iter().any(|s| s.eq(query)),
+                        // Samples with no values for this field are automatically
+                        // filtered as described in the rules for filtering.
+                        None => false,
+                    }
+                }
+            })
+            .collect::<Vec<_>>()
+    }
+}
diff --git a/crates/ccdi-server/src/params/filter.rs b/crates/ccdi-server/src/params/filter.rs
index 827589e..9cee2ef 100644
--- a/crates/ccdi-server/src/params/filter.rs
+++ b/crates/ccdi-server/src/params/filter.rs
@@ -161,6 +161,122 @@ pub struct Sample {
     #[serde(default, skip_serializing_if = "Option::is_none")]
     #[param(required = false, nullable = false)]
     pub depositions: Option<String>,
+
+    /// Matches any sample where the `diagnosis` field matches the
+    /// string provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub diagnosis: Option<String>,
+}
+
+/// Parameters for filtering experimental sample-diagnosis endpoint.
+///
+/// None of the parameters are required, but they may be provided as a
+/// [`String`]. When a parameter is provided, the endpoint will filter the
+/// results to only include [`Sample`]s where the value for the key exactly
+/// matches the value provided for the parameter (i.e., matching is done by
+/// looking for the provided parameter as a substring). Matches are
+/// case-sensitive.
+/// For the "search" parameter only, matching is case-insensitive and requires
+/// only a substring match rather than an exact match.
+#[derive(Debug, Default, Deserialize, IntoParams, Introspect, Serialize)]
+#[into_params(parameter_in = Query)]
+pub struct SampleDiagnosis {
+    /// Matches any sample where the `diagnosis` field contains the
+    /// string provided, ignoring case.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub search: Option<String>,
+
+    /// Matches any sample where the `disease_phase` field matches the string
+    /// provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub disease_phase: Option<String>,
+
+    /// Matches any sample where the `anatomical_sites` field matches the string
+    /// provided.
+    ///
+    /// **Note:** a logical OR (`||`) is performed across the values when
+    /// determining whether the subject should be included in the results.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub anatomical_sites: Option<String>,
+
+    /// Matches any sample where the `library_selection_method` field matches the string
+    /// provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub library_selection_method: Option<String>,
+
+    /// Matches any sample where the `library_strategy` field matches the string
+    /// provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub library_strategy: Option<String>,
+
+    /// Matches any sample where the `library_source_material` field matches the string
+    /// provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub library_source_material: Option<String>,
+
+    /// Matches any sample where the `preservation_method` field matches the string
+    /// provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub preservation_method: Option<String>,
+
+    /// Matches any sample where the `specimen_molecular_analyte_type` field matches the string
+    /// provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub specimen_molecular_analyte_type: Option<String>,
+
+    /// Matches any sample where the `tissue_type` field matches the string
+    /// provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub tissue_type: Option<String>,
+
+    /// Matches any sample where the `tumor_classification` field matches the
+    /// string provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub tumor_classification: Option<String>,
+
+    /// Matches any sample where the `age_at_diagnosis` field matches the string
+    /// provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub age_at_diagnosis: Option<String>,
+
+    /// Matches any sample where the `age_at_collection` field matches the
+    /// string provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub age_at_collection: Option<String>,
+
+    /// Matches any sample where the `tumor_tissue_morphology` field matches the
+    /// string provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub tumor_tissue_morphology: Option<String>,
+
+    /// Matches any sample where any member of the `depositions` fields match
+    /// the string provided.
+    ///
+    /// **Note:** a logical OR (`||`) is performed across the values when
+    /// determining whether the sample should be included in the results.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub depositions: Option<String>,
+
+    /// Matches any sample where the `diagnosis` field matches the
+    /// string provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub diagnosis: Option<String>,
 }
 
 /// Parameters for filtering files.
diff --git a/crates/ccdi-server/src/routes.rs b/crates/ccdi-server/src/routes.rs
index 595191b..5626ec5 100644
--- a/crates/ccdi-server/src/routes.rs
+++ b/crates/ccdi-server/src/routes.rs
@@ -6,6 +6,7 @@ pub mod metadata;
 pub mod namespace;
 pub mod organization;
 pub mod sample;
+pub mod sample_diagnosis;
 pub mod subject;
 
 /// A result for a group by operation.
diff --git a/crates/ccdi-server/src/routes/sample.rs b/crates/ccdi-server/src/routes/sample.rs
index e2b9cf2..a5d45b1 100644
--- a/crates/ccdi-server/src/routes/sample.rs
+++ b/crates/ccdi-server/src/routes/sample.rs
@@ -418,6 +418,18 @@ fn parse_field(field: &str, sample: &Sample) -> Option<Option<Value>> {
             ),
             None => Some(None),
         },
+        "diagnosis" => match sample.metadata() {
+            Some(metadata) => Some(
+                metadata
+                    .diagnosis()
+                    .as_ref()
+                    // SAFETY: all metadata fields are able to be represented as
+                    // [`serde_json::Value`]s.
+                    .map(|diagnosis| serde_json::to_value(diagnosis.value()).unwrap())
+                    .or(Some(Value::Null)),
+            ),
+            None => Some(None),
+        },
         "disease_phase" => match sample.metadata() {
             Some(metadata) => Some(
                 metadata
diff --git a/crates/ccdi-server/src/routes/sample_diagnosis.rs b/crates/ccdi-server/src/routes/sample_diagnosis.rs
new file mode 100644
index 0000000..1ddfa52
--- /dev/null
+++ b/crates/ccdi-server/src/routes/sample_diagnosis.rs
@@ -0,0 +1,194 @@
+//! Routes related to the experimental sample-diagnosis endpoint.
+
+use actix_web::get;
+use actix_web::web::Data;
+use actix_web::web::Query;
+use actix_web::web::ServiceConfig;
+use actix_web::Responder;
+
+use ccdi_models as models;
+
+use models::Sample;
+
+use crate::filter::filter;
+use crate::paginate;
+use crate::params::filter::SampleDiagnosis as FilterSampleDiagnosisParams;
+use crate::params::PaginationParams;
+use crate::responses::error;
+use crate::responses::Errors;
+use crate::responses::Samples;
+
+use crate::routes::sample::Store;
+
+/// Configures the [`ServiceConfig`] with the sample-diagnosis paths.
+/// This uses the existing data store made by the samples object.
+pub fn configure(store: Data<Store>) -> impl FnOnce(&mut ServiceConfig) {
+    |config: &mut ServiceConfig| {
+        config.app_data(store).service(sample_diagnosis_index);
+    }
+}
+
+/// Experimental: Filter the samples known by this server by free-text diagnosis search.
+///
+/// ### Diagnosis Filtering
+///
+/// This endpoint supports the experimental `search` parameter.
+/// For this parameter, the sample is included in the results if the value of its
+/// its `diagnosis` field _contains_ the query string, or if an unharmonized field
+/// treated by the implementer as a diagnosis field contains that query string.
+/// Matches are case-insensitive.
+///
+/// ### Pagination
+///
+/// This endpoint is paginated. Users may override the default pagination
+/// parameters by providing one or more of the pagination-related query
+/// parameters below.
+///
+/// ### Additional Filtering
+///
+/// All harmonized (top-level) and unharmonized (nested under the
+/// `metadata.unharmonized` key) metadata fields are filterable. To achieve
+/// this, you can provide the field name as a [`String`]. Filtering follows the
+/// following rules:
+///
+/// * For single-value metadata field, the sample is included in the results if
+///   its value _exactly_ matches the query string. Matches are case-sensitive.
+/// * For multiple-value metadata fields, the sample is included in the results
+///   if any of its values for the field _exactly_ match the query string (a
+///   logical OR [`||`]). Matches are case-sensitive.
+/// * When the metadata field is `null` (in the case of singular or
+///   multiple-valued metadata fields) or empty, the sample is not included.
+/// * When multiple fields are provided as filters, a logical AND (`&&`) strings
+///   together the predicates. In other words, all filters must match for a
+///   sample to be returned. Note that this means that servers do not natively
+///   support logical OR (`||`) across multiple fields: that must be done by
+///   calling this endpoint with each of your desired queries and performing a
+///   set union of those samples out of band.
+///
+/// ### Ordering
+///
+/// This endpoint has default ordering requirements—those details are documented
+/// in the `responses::Samples` schema.
+#[utoipa::path(
+    get,
+    path = "/sample-diagnosis",
+    tag = "Experimental",
+    params(
+        FilterSampleDiagnosisParams,
+        (
+            "metadata.unharmonized.<field>" = Option<String>,
+            Query,
+            nullable = false,
+            description = "All unharmonized fields should be filterable in the \
+            same manner as harmonized fields:\n\n\
+            * Filtering on a singular field should include the `Sample` in \
+            the results if the query exactly matches the value of that field \
+            for the `Sample` (case-sensitive).\n\
+            * Filtering on field with multiple values should include the \
+            `Sample` in the results if the query exactly matches any of the \
+            values of the field for that `Sample` (case-sensitive).\n\
+            * Unlike harmonized fields, unharmonized fields must be prefixed \
+            with `metadata.unharmonized`.\n\n\
+            **Note:** this query parameter is intended to be symbolic of any \
+            unharmonized field. Because of limitations within Swagger UI, it \
+            will show up as a query parameter that can be optionally be \
+            submitted as part of a request within Swagger UI. Please keep in \
+            mind that the literal query parameter \
+            `?metadata.unharmonized.<field>=value` is not supported, so \
+            attempting to use it within Swagger UI will not work!"
+        ),
+        PaginationParams,
+    ),
+    responses(
+        (
+            status = 200,
+            description = "Successful operation.",
+            body = responses::Samples,
+            headers(
+                (
+                    "link" = String,
+                    description = "Links to URLs that may be of interest \
+                    when paging through paginated responses. This header \
+                    contains two or more links of interest. The format of the \
+                    field is as follows: \
+                    \n\
+                    \n`Link: <URL>; rel=\"REL\"` \
+                    \n\
+                    ### Relationships\n\n\
+                    In the format above, `URL` represents a valid URL for \
+                    the link of interest and `REL` is one of four values: \n\
+                    - `first` (_Required_). A link to the first page in the \
+                    results (can be the same as `last` if there is only one \
+                    page).\n\
+                    - `last` (_Required_). A link to the first page in the \
+                    results (can be the same as `first` if there is only one \
+                    page).\n\
+                    - `next` (_Optional_). A link to the next page (if it \
+                    exists).\n\
+                    - `prev` (_Optional_). A link to the previous page (if it \
+                    exists).\n\n\
+                    ### Requirements\n\n\
+                    - This header _must_ provide links for at least the `first` \
+                    and `last` rels.\n \
+                    - The `prev` and `next` links must exist only (a) when there \
+                    are multiple pages in the result page set and (b) when the \
+                    current page is not the first or last page, respectively.\n\
+                    - This list of links is unordered.\n\n \
+                    ### Notes\n\n\
+                    - HTTP 1.1 and HTTP 2.0 dictate that response \
+                    headers are case insensitive. Though not required, we \
+                    recommend an all lowercase name of `link` for this \
+                    response header."
+                )
+            )
+        ),
+        (
+            status = 404,
+            description = "Not found.\nServers that cannot provide line-level \
+            data should use this response rather than Forbidden (403), as \
+            there is no level of authorization that would allow one to access \
+            the information included in the API.",
+            body = responses::Errors,
+            example = json!(
+                Errors::from(
+                    error::Kind::unshareable_data(
+                        String::from("samples"),
+                        String::from(
+                            "Our agreement with data providers prohibits us from sharing \
+                            line-level data."
+                        ),
+                    )
+                )
+            )
+        ),
+        (
+            status = 422,
+            description = "Invalid query or path parameters.",
+            body = responses::Errors,
+            example = json!(Errors::from(error::Kind::invalid_parameters(
+                Some(vec![String::from("page"), String::from("per_page")]),
+                String::from("unable to calculate offset")
+            )))
+        ),
+    )
+)]
+#[get("/sample-diagnosis")]
+pub async fn sample_diagnosis_index(
+    filter_params: Query<FilterSampleDiagnosisParams>,
+    pagination_params: Query<PaginationParams>,
+    samples: Data<Store>,
+) -> impl Responder {
+    let mut samples = samples.samples.lock().unwrap().clone();
+
+    // See the note in the documentation for this endpoint: the results must be
+    // sorted by identifier by default.
+    samples.sort();
+
+    let samples = filter::<Sample, FilterSampleDiagnosisParams>(samples, filter_params.0);
+
+    paginate::response::<Sample, Samples>(
+        pagination_params.0,
+        samples,
+        "http://localhost:8000/sample-diagnosis",
+    )
+}
diff --git a/crates/ccdi-spec/src/main.rs b/crates/ccdi-spec/src/main.rs
index 670b51c..d99c6c3 100644
--- a/crates/ccdi-spec/src/main.rs
+++ b/crates/ccdi-spec/src/main.rs
@@ -40,6 +40,7 @@ use server::routes::info;
 use server::routes::metadata;
 use server::routes::namespace;
 use server::routes::sample;
+use server::routes::sample_diagnosis;
 use server::routes::subject;
 
 mod utils;
@@ -339,6 +340,7 @@ fn inner() -> Result<(), Box<dyn std::error::Error>> {
                         .configure(namespace::configure())
                         .configure(organization::configure())
                         .configure(info::configure())
+                        .configure(sample_diagnosis::configure(samples.clone()))
                         .service(
                             SwaggerUi::new("/swagger-ui/{_:.*}")
                                 .url("/api-docs/openapi.json", Api::openapi()),
diff --git a/swagger.yml b/swagger.yml
index 2a952c2..82356c2 100644
--- a/swagger.yml
+++ b/swagger.yml
@@ -454,6 +454,14 @@ paths:
         required: false
         schema:
           type: string
+      - name: diagnosis
+        in: query
+        description: |-
+          Matches any sample where the `diagnosis` field matches the
+          string provided.
+        required: false
+        schema:
+          type: string
       - name: metadata.unharmonized.<field>
         in: query
         description: |-
@@ -1034,6 +1042,258 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/responses.Information'
+  /sample-diagnosis:
+    get:
+      tags:
+      - Experimental
+      summary: 'Experimental: Filter the samples known by this server by free-text diagnosis search.'
+      description: |-
+        Experimental: Filter the samples known by this server by free-text diagnosis search.
+
+        ### Diagnosis Filtering
+
+        This endpoint supports the experimental `search` parameter.
+        For this parameter, the sample is included in the results if the value of its
+        its `diagnosis` field _contains_ the query string, or if an unharmonized field
+        treated by the implementer as a diagnosis field contains that query string.
+        Matches are case-insensitive.
+
+        ### Pagination
+
+        This endpoint is paginated. Users may override the default pagination
+        parameters by providing one or more of the pagination-related query
+        parameters below.
+
+        ### Additional Filtering
+
+        All harmonized (top-level) and unharmonized (nested under the
+        `metadata.unharmonized` key) metadata fields are filterable. To achieve
+        this, you can provide the field name as a [`String`]. Filtering follows the
+        following rules:
+
+        * For single-value metadata field, the sample is included in the results if
+        its value _exactly_ matches the query string. Matches are case-sensitive.
+        * For multiple-value metadata fields, the sample is included in the results
+        if any of its values for the field _exactly_ match the query string (a
+        logical OR [`||`]). Matches are case-sensitive.
+        * When the metadata field is `null` (in the case of singular or
+        multiple-valued metadata fields) or empty, the sample is not included.
+        * When multiple fields are provided as filters, a logical AND (`&&`) strings
+        together the predicates. In other words, all filters must match for a
+        sample to be returned. Note that this means that servers do not natively
+        support logical OR (`||`) across multiple fields: that must be done by
+        calling this endpoint with each of your desired queries and performing a
+        set union of those samples out of band.
+
+        ### Ordering
+
+        This endpoint has default ordering requirements—those details are documented
+        in the `responses::Samples` schema.
+      operationId: sample_diagnosis_index
+      parameters:
+      - name: search
+        in: query
+        description: |-
+          Matches any sample where the `diagnosis` field contains the
+          string provided, ignoring case.
+        required: false
+        schema:
+          type: string
+      - name: disease_phase
+        in: query
+        description: |-
+          Matches any sample where the `disease_phase` field matches the string
+          provided.
+        required: false
+        schema:
+          type: string
+      - name: anatomical_sites
+        in: query
+        description: |-
+          Matches any sample where the `anatomical_sites` field matches the string
+          provided.
+
+          **Note:** a logical OR (`||`) is performed across the values when
+          determining whether the subject should be included in the results.
+        required: false
+        schema:
+          type: string
+      - name: library_selection_method
+        in: query
+        description: |-
+          Matches any sample where the `library_selection_method` field matches the string
+          provided.
+        required: false
+        schema:
+          type: string
+      - name: library_strategy
+        in: query
+        description: |-
+          Matches any sample where the `library_strategy` field matches the string
+          provided.
+        required: false
+        schema:
+          type: string
+      - name: library_source_material
+        in: query
+        description: |-
+          Matches any sample where the `library_source_material` field matches the string
+          provided.
+        required: false
+        schema:
+          type: string
+      - name: preservation_method
+        in: query
+        description: |-
+          Matches any sample where the `preservation_method` field matches the string
+          provided.
+        required: false
+        schema:
+          type: string
+      - name: specimen_molecular_analyte_type
+        in: query
+        description: |-
+          Matches any sample where the `specimen_molecular_analyte_type` field matches the string
+          provided.
+        required: false
+        schema:
+          type: string
+      - name: tissue_type
+        in: query
+        description: |-
+          Matches any sample where the `tissue_type` field matches the string
+          provided.
+        required: false
+        schema:
+          type: string
+      - name: tumor_classification
+        in: query
+        description: |-
+          Matches any sample where the `tumor_classification` field matches the
+          string provided.
+        required: false
+        schema:
+          type: string
+      - name: age_at_diagnosis
+        in: query
+        description: |-
+          Matches any sample where the `age_at_diagnosis` field matches the string
+          provided.
+        required: false
+        schema:
+          type: string
+      - name: age_at_collection
+        in: query
+        description: |-
+          Matches any sample where the `age_at_collection` field matches the
+          string provided.
+        required: false
+        schema:
+          type: string
+      - name: tumor_tissue_morphology
+        in: query
+        description: |-
+          Matches any sample where the `tumor_tissue_morphology` field matches the
+          string provided.
+        required: false
+        schema:
+          type: string
+      - name: depositions
+        in: query
+        description: |-
+          Matches any sample where any member of the `depositions` fields match
+          the string provided.
+
+          **Note:** a logical OR (`||`) is performed across the values when
+          determining whether the sample should be included in the results.
+        required: false
+        schema:
+          type: string
+      - name: diagnosis
+        in: query
+        description: |-
+          Matches any sample where the `diagnosis` field matches the
+          string provided.
+        required: false
+        schema:
+          type: string
+      - name: metadata.unharmonized.<field>
+        in: query
+        description: |-
+          All unharmonized fields should be filterable in the same manner as harmonized fields:
+
+          * Filtering on a singular field should include the `Sample` in the results if the query exactly matches the value of that field for the `Sample` (case-sensitive).
+          * Filtering on field with multiple values should include the `Sample` in the results if the query exactly matches any of the values of the field for that `Sample` (case-sensitive).
+          * Unlike harmonized fields, unharmonized fields must be prefixed with `metadata.unharmonized`.
+
+          **Note:** this query parameter is intended to be symbolic of any unharmonized field. Because of limitations within Swagger UI, it will show up as a query parameter that can be optionally be submitted as part of a request within Swagger UI. Please keep in mind that the literal query parameter `?metadata.unharmonized.<field>=value` is not supported, so attempting to use it within Swagger UI will not work!
+        required: false
+        schema:
+          type: string
+      - name: page
+        in: query
+        description: |-
+          The page to retrieve.
+
+          This is a 1-based index of a page within a page set. The value of `page`
+          **must** default to `1` when this parameter is not provided.
+        required: false
+        schema:
+          type: integer
+          minimum: 0
+      - name: per_page
+        in: query
+        description: |-
+          The number of results per page.
+
+          Each server can select its own default value for `per_page` when this
+          parameter is not provided. That said, the convention within the
+          community is to use `100` as a default value if any value is equally
+          reasonable.
+        required: false
+        schema:
+          type: integer
+          minimum: 0
+      responses:
+        '200':
+          description: Successful operation.
+          headers:
+            link:
+              schema:
+                type: string
+              description: "Links to URLs that may be of interest when paging through paginated responses. This header contains two or more links of interest. The format of the field is as follows: \n\n`Link: <URL>; rel=\"REL\"` \n### Relationships\n\nIn the format above, `URL` represents a valid URL for the link of interest and `REL` is one of four values: \n- `first` (_Required_). A link to the first page in the results (can be the same as `last` if there is only one page).\n- `last` (_Required_). A link to the first page in the results (can be the same as `first` if there is only one page).\n- `next` (_Optional_). A link to the next page (if it exists).\n- `prev` (_Optional_). A link to the previous page (if it exists).\n\n### Requirements\n\n- This header _must_ provide links for at least the `first` and `last` rels.\n - The `prev` and `next` links must exist only (a) when there are multiple pages in the result page set and (b) when the current page is not the first or last page, respectively.\n- This list of links is unordered.\n\n ### Notes\n\n- HTTP 1.1 and HTTP 2.0 dictate that response headers are case insensitive. Though not required, we recommend an all lowercase name of `link` for this response header."
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/responses.Samples'
+        '404':
+          description: |-
+            Not found.
+            Servers that cannot provide line-level data should use this response rather than Forbidden (403), as there is no level of authorization that would allow one to access the information included in the API.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/responses.Errors'
+              example:
+                errors:
+                - kind: UnshareableData
+                  entity: Samples
+                  reason: Our agreement with data providers prohibits us from sharing line-level data.
+                  message: 'Unable to share data for samples: our agreement with data providers prohibits us from sharing line-level data.'
+        '422':
+          description: Invalid query or path parameters.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/responses.Errors'
+              example:
+                errors:
+                - kind: InvalidParameters
+                  parameters:
+                  - page
+                  - per_page
+                  reason: Unable to calculate offset.
+                  message: 'Invalid value for parameters ''page'' and ''per_page'': unable to calculate offset.'
 components:
   schemas:
     cde.v1.deposition.DbgapPhsAccession:
@@ -18769,6 +19029,8 @@ tags:
   description: List and describe organizations known by this server.
 - name: Info
   description: Information about the API implementation itself.
+- name: Experimental
+  description: Endpoints and features in an experimental phase.
 externalDocs:
   url: https://www.cancer.gov/research/areas/childhood/childhood-cancer-data-initiative
   description: Learn more about the Childhood Cancer Data Initiative


Reviews:
user: login: grugna
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T19:51:34Z
user: login: grugna
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-02T19:52:10Z
user: login: jsunny23
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-05T15:11:44Z
user: login: asafieva
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:19:55Z
user: login: asafieva
body: Please remove "sample"."search" query parameter since it shall be in a separate endpoint.
The changes related to "sample"."diagnosis" query parameter are OK to merge.

state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:22:56Z
user: login: mcrusch
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-07T20:25:48Z
user: login: e-t-k
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T07:10:52Z
user: login: e-t-k
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T07:55:19Z
user: login: e-t-k
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T07:56:01Z
user: login: e-t-k
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-08T07:56:15Z
user: login: mcrusch
body: Tested again with the latest changes.  Swagger doc and API testing both looked good: search present and working in sample-diagnosis, but absent from sample.  I approve.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-08T19:16:13Z
user: login: asafieva
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-09T02:19:19Z
user: login: e-t-k
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T23:09:51Z
user: login: asafieva
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-13T17:48:57Z

Review comments:
diff_hunk: @@ -36,82 +38,116 @@ impl FilterMetadataField<Sample, FilterSampleParams> for Vec<Sample> {
 
         self.into_iter()
             .filter(|sample| {
-                let values: Option<Vec<String>> = match field.as_str() {
-                    "anatomical_sites" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.anatomical_sites())
-                        .map(|sites| {
-                            sites
-                                .iter()
-                                .map(|site| site.to_string())
-                                .collect::<Vec<_>>()
-                        }),
-                    "disease_phase" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.disease_phase())
-                        .map(|disease_phase| vec![disease_phase.to_string()]),
-                    "library_selection_method" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.library_selection_method())
-                        .map(|library_selection_method| vec![library_selection_method.to_string()]),
-                    "library_strategy" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.library_strategy())
-                        .map(|library_strategy| vec![library_strategy.to_string()]),
-                    "library_source_material" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.library_source_material())
-                        .map(|library_source_material| vec![library_source_material.to_string()]),
-                    "preservation_method" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.preservation_method())
-                        .map(|preservation_method| vec![preservation_method.to_string()]),
-                    "specimen_molecular_analyte_type" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.specimen_molecular_analyte_type())
-                        .map(|specimen_molecular_analyte_type| {
-                            vec![specimen_molecular_analyte_type.to_string()]
-                        }),
-                    "tissue_type" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.tissue_type())
-                        .map(|tissue_type| vec![tissue_type.to_string()]),
-                    "tumor_classification" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.tumor_classification())
-                        .map(|tumor_classification| vec![tumor_classification.to_string()]),
-                    "age_at_diagnosis" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.age_at_diagnosis())
-                        .map(|age_at_diagnosis| vec![age_at_diagnosis.to_string()]),
-                    "age_at_collection" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.age_at_collection())
-                        .map(|age_at_collection| vec![age_at_collection.to_string()]),
-                    "tumor_tissue_morphology" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.tumor_tissue_morphology())
-                        .map(|tumor_tissue_morphology| vec![tumor_tissue_morphology.to_string()]),
-                    "depositions" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.common().depositions())
-                        .map(|deposition| {
-                            deposition
-                                .iter()
-                                .cloned()
-                                .map(|accession| match accession {
-                                    Accession::dbGaP(accession) => accession.to_string(),
-                                })
-                                .collect::<Vec<String>>()
-                        }),
-                    _ => unreachable!("unhandled sample metadata field: {field}"),
-                };
+                // Search field matches by substring rather than exact match.
+                if field.as_str() == "search" {
+                    if let Some(metadata) = sample.metadata() {
+                        if let Some(diagnosis) = metadata.diagnosis() {
+                            // Only return the entry if the query is a substring
+                            // of the diagnosis, ignoring case.
+                            // Matching on to_lowercase is an approximation and will not cover
+                            // all unicode characters.
+                            let diagnosis_lower = diagnosis.to_string().to_lowercase();
+                            let query_lower = query.to_string().to_lowercase();
+                            return diagnosis_lower.to_string().contains(&query_lower);
+                        }
 
-                match values {
-                    Some(values) => values.into_iter().any(|s| s.eq(query)),
-                    // Samples with no values for this field are automatically
-                    // filtered as described in the rules for filtering.
-                    None => false,
+                        // If the metadata doesn't have a diagnosis, the entry
+                        // should not be included.
+                        return false;
+                    }
+
+                    // If no metadata is included, this entry should not be
+                    // included.
+                    false
path: crates/ccdi-server/src/filter/sample.rs
user: login: grugna
body: I assume you meant `return false`?
created_at: 2025-05-02T19:51:34Z
author_association: COLLABORATOR
path: swagger.yml
user: login: asafieva
body: "diagnosis" search query parameter is added all right.
"search" query parameter added is not expected in "sample" endpoint.
There is no "sample-diagnosis" endpoint added.

created_at: 2025-05-07T18:19:55Z
author_association: COLLABORATOR
diff_hunk: @@ -161,6 +163,18 @@ pub struct Sample {
     #[serde(default, skip_serializing_if = "Option::is_none")]
     #[param(required = false, nullable = false)]
     pub depositions: Option<String>,
+
+    /// Matches any sample where the `diagnosis` field matches the
+    /// string provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub diagnosis: Option<String>,
+
+    /// Matches any sample where the `diagnosis` field contains the
+    /// string provided, ignoring case.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub search: Option<String>,
path: crates/ccdi-server/src/params/filter.rs
user: login: mcrusch
body: By adding "search" here, it is automatically available to the regular `sample` endpoint.  To restrict it to the experimental endpoint, it needs to be in a separate struct.  Clay recommended using composition + serde flatten.  Basically, a new struct after Sample called SampleDiagnosis or SampleExperimental that has a Sample, but is flattened in the serialized form.  So, perhaps something like (sorry, my rust is not good):
```suggestion
}

#[derive(Debug, Default, Deserialize, IntoParams, Introspect, Serialize)]
#[into_params(parameter_in = Query)]
pub struct SampleDiagnosis {
    #[serde(flatten)]
    pub sample: Sample,

    /// Matches any sample where the `diagnosis` field contains the
    /// string provided, ignoring case.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[param(required = false, nullable = false)]
    pub search: Option<String>,
```
created_at: 2025-05-07T20:19:21Z
author_association: COLLABORATOR
path: crates/ccdi-server/src/routes/sample.rs
user: login: mcrusch
body: To keep the new search functionality separate, recommend creating a new file for the route sample_diagnosis.rs (or sample-diagnosis.rs?).  The new route would use the SampleDiagnosis filter instead of the Sample filter to get the extra parameter.  Clay said the macros are hard to inherit so the easiest way to do this would actually be to copy sample.rs to sample_diagnosis.rs and then update it.

So, the only update to sample.rs would be to add the exact querying for diagnosis.  That plus the other changes would be in sample_diagnosis.rs.

Finally, to get the new endpoint to show up in the swagger doc, add it to crates/ccdi-openapi/src/api.rs.  I think we should create a new section there for experimental endpoints and group the two new endpoints that way.  Alternatively, they could go under the sample and subject sections, respectively.
created_at: 2025-05-07T20:22:29Z
author_association: COLLABORATOR
diff_hunk: @@ -36,82 +38,116 @@ impl FilterMetadataField<Sample, FilterSampleParams> for Vec<Sample> {
 
         self.into_iter()
             .filter(|sample| {
-                let values: Option<Vec<String>> = match field.as_str() {
-                    "anatomical_sites" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.anatomical_sites())
-                        .map(|sites| {
-                            sites
-                                .iter()
-                                .map(|site| site.to_string())
-                                .collect::<Vec<_>>()
-                        }),
-                    "disease_phase" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.disease_phase())
-                        .map(|disease_phase| vec![disease_phase.to_string()]),
-                    "library_selection_method" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.library_selection_method())
-                        .map(|library_selection_method| vec![library_selection_method.to_string()]),
-                    "library_strategy" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.library_strategy())
-                        .map(|library_strategy| vec![library_strategy.to_string()]),
-                    "library_source_material" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.library_source_material())
-                        .map(|library_source_material| vec![library_source_material.to_string()]),
-                    "preservation_method" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.preservation_method())
-                        .map(|preservation_method| vec![preservation_method.to_string()]),
-                    "specimen_molecular_analyte_type" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.specimen_molecular_analyte_type())
-                        .map(|specimen_molecular_analyte_type| {
-                            vec![specimen_molecular_analyte_type.to_string()]
-                        }),
-                    "tissue_type" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.tissue_type())
-                        .map(|tissue_type| vec![tissue_type.to_string()]),
-                    "tumor_classification" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.tumor_classification())
-                        .map(|tumor_classification| vec![tumor_classification.to_string()]),
-                    "age_at_diagnosis" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.age_at_diagnosis())
-                        .map(|age_at_diagnosis| vec![age_at_diagnosis.to_string()]),
-                    "age_at_collection" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.age_at_collection())
-                        .map(|age_at_collection| vec![age_at_collection.to_string()]),
-                    "tumor_tissue_morphology" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.tumor_tissue_morphology())
-                        .map(|tumor_tissue_morphology| vec![tumor_tissue_morphology.to_string()]),
-                    "depositions" => sample
-                        .metadata()
-                        .and_then(|metadata| metadata.common().depositions())
-                        .map(|deposition| {
-                            deposition
-                                .iter()
-                                .cloned()
-                                .map(|accession| match accession {
-                                    Accession::dbGaP(accession) => accession.to_string(),
-                                })
-                                .collect::<Vec<String>>()
-                        }),
-                    _ => unreachable!("unhandled sample metadata field: {field}"),
-                };
+                // Search field matches by substring rather than exact match.
+                if field.as_str() == "search" {
+                    if let Some(metadata) = sample.metadata() {
+                        if let Some(diagnosis) = metadata.diagnosis() {
+                            // Only return the entry if the query is a substring
+                            // of the diagnosis, ignoring case.
+                            // Matching on to_lowercase is an approximation and will not cover
+                            // all unicode characters.
+                            let diagnosis_lower = diagnosis.to_string().to_lowercase();
+                            let query_lower = query.to_string().to_lowercase();
+                            return diagnosis_lower.to_string().contains(&query_lower);
+                        }
 
-                match values {
-                    Some(values) => values.into_iter().any(|s| s.eq(query)),
-                    // Samples with no values for this field are automatically
-                    // filtered as described in the rules for filtering.
-                    None => false,
+                        // If the metadata doesn't have a diagnosis, the entry
+                        // should not be included.
+                        return false;
+                    }
+
+                    // If no metadata is included, this entry should not be
+                    // included.
+                    false
path: crates/ccdi-server/src/filter/sample.rs
user: login: e-t-k
body: @grugna Yes, I would assume that too! But when I changed it to `return false;` just now I got an error  from `cargo clippy -- -D warnings` linking to the [Clippy Lints: needless return](https://rust-lang.github.io/rust-clippy/master/index.html#needless_return) page.

So, I will leave it as `false` so that this code is more "rusty". Thanks for checking.
created_at: 2025-05-08T07:10:52Z
author_association: COLLABORATOR
path: swagger.yml
user: login: e-t-k
body: Thank you! I have fixed these.
created_at: 2025-05-08T07:55:19Z
author_association: COLLABORATOR
diff_hunk: @@ -161,6 +163,18 @@ pub struct Sample {
     #[serde(default, skip_serializing_if = "Option::is_none")]
     #[param(required = false, nullable = false)]
     pub depositions: Option<String>,
+
+    /// Matches any sample where the `diagnosis` field matches the
+    /// string provided.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub diagnosis: Option<String>,
+
+    /// Matches any sample where the `diagnosis` field contains the
+    /// string provided, ignoring case.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[param(required = false, nullable = false)]
+    pub search: Option<String>,
path: crates/ccdi-server/src/params/filter.rs
user: login: e-t-k
body: This is really helpful. I wasn't confident in my understanding of how to use composition so I ended up copy-pasting the existing Sample query parameters into SampleDiagnosis instead. But something to look into for Subject! 

created_at: 2025-05-08T07:56:00Z
author_association: COLLABORATOR
path: crates/ccdi-server/src/routes/sample.rs
user: login: e-t-k
body: Thanks so much for this guidance! It was incredibly helpful, especially the pointer to `api.rs` to update the swagger doc.
created_at: 2025-05-08T07:56:15Z
author_association: COLLABORATOR
path: swagger.yml
user: login: asafieva
body: @e-t-k swagger.yml "Experimental" tag shall be added to "tags" YAML section (Ln 19017).
created_at: 2025-05-09T02:19:18Z
author_association: COLLABORATOR
path: swagger.yml
user: login: e-t-k
body: @asafieva resolved in https://github.com/CBIIT/ccdi-federation-api/pull/139/commits/d92ac10bd98b0ea783c5561118b0b0cfbed7726e , thanks for catching!
created_at: 2025-05-12T23:09:51Z
author_association: COLLABORATOR

PR comments:
user: login: grugna
created_at: 2025-05-02T19:53:30Z
body: Since you mentioned you would have made the `subject-diagnosis endpoints` in a different PR could you just rename this one from `feat: experimental sample-diagnosis and subject-diagnosis endpoints` to `feat: experimental sample-diagnosis`?
author_association: COLLABORATOR
user: login: asafieva
created_at: 2025-05-05T17:04:14Z
body: @e-t-k  I see a query parameter "search" was added to "sample" endpoint to improve disease querying by name patterns. Since this disease querying returns the same entities as other sample querying requests by parameters, do we opt for now to just having this extra "sample" parameter instead of creating a new endpoint "sample-diagnosis"? 

When Michael suggested adding new endpoints one of the arguments was not to touch the existed implementation v1.1.0. Adding an extra parameter might be an easier way than adding a new endpoint so. If we include "search" parameter to "sample" would it be a commitment to implement this querying in "sample"?
Thank you.
author_association: COLLABORATOR
user: login: mcrusch
created_at: 2025-05-06T20:30:18Z
body: > @e-t-k I see a query parameter "search" was added to "sample" endpoint to improve disease querying by name patterns. Since this disease querying returns the same entities as other sample querying requests by parameters, do we opt for now to just having this extra "sample" parameter instead of creating a new endpoint "sample-diagnosis"?
> 
> When Michael suggested adding new endpoints one of the arguments was not to touch the existed implementation v1.1.0. Adding an extra parameter might be an easier way than adding a new endpoint so. If we include "search" parameter to "sample" would it be a commitment to implement this querying in "sample"? Thank you.

I would advocate for keeping it as a separate endpoint for now for a few reasons:

1. Currently this is a one-off querying method, inconsistent with how all of the other querying works.
2. I don't think this search method is the ideal end state.  If we were all harmonized to a single tree, then it would probably make more sense to do exact search based on code for the nodes in the tree and retrieve everything under that node.  Or, if we were going to stick with text-based, then we might want to consider extending that to other elements, or using a more sophisticated full text search with stemming to account for different spelling.  I'd prefer to hold off on integrating into the main endpoint until it's more finalized.
3. Changes to the design at this point would add risk to the timeline.
author_association: COLLABORATOR
user: login: mcrusch
created_at: 2025-05-06T20:31:25Z
body: I reviewed the example API, and it looked correct and worked well.  I don't see the new endpoint in the swagger docs, though.  Was that intentional because it's experimental?  I was thinking it would be in there, and that would be the place where we explain the fact that it's experimental and subject to change.
author_association: COLLABORATOR
user: login: mcrusch
created_at: 2025-05-07T18:09:43Z
body: I double-checked my review, and I missed some things before.

It looks to me like there is both a new endpoint `sample-diagnosis` that accepts the `search` query, AND also the `search` query is supported on the `sample` endpoint.  I think we only want `search` on the new endpoint, not the `sample` endpoint.

Also, the Swagger doc shows the new `search` query under `sample`, but doesn't show the new endpoint.
author_association: COLLABORATOR
user: login: e-t-k
created_at: 2025-05-08T07:58:23Z
body: Hi all, thanks so much for the discussion. I've addressed the changes requested. At a high level:
 - The `search` query parameter has been removed from `sample` endpoint.
 - The new endpoint `sample-diagnosis` has been added to the swagger docs under an **Experimental** header.
 - Per [@grugna's suggestion](https://github.com/CBIIT/ccdi-federation-api/pull/139#issuecomment-2847995501), updated title to mention sample-diagnosis only.

Since this most recent commit changed a lot of code, I've added a quick overview of the changes to each file in [the commit text](https://github.com/CBIIT/ccdi-federation-api/pull/139/commits/ff33f02e5edc6a70347d0e882f21de64b0a96531) -- please take a look.
author_association: COLLABORATOR