Skip to content

Commit 3111216

Browse files
committed
Nullable<T> for nullable but not optional, fix setting null
1 parent 680521e commit 3111216

File tree

6 files changed

+113
-14
lines changed

6 files changed

+113
-14
lines changed

common/src/api/external/mod.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ use std::fmt::Result as FormatResult;
4343
use std::net::IpAddr;
4444
use std::net::Ipv4Addr;
4545
use std::num::{NonZeroU16, NonZeroU32};
46+
use std::ops::Deref;
4647
use std::str::FromStr;
4748
use tufaceous_artifact::ArtifactHash;
4849
use uuid::Uuid;
@@ -3266,6 +3267,60 @@ pub enum ImportExportPolicy {
32663267
Allow(Vec<oxnet::IpNet>),
32673268
}
32683269

3270+
/// Use instead of Option in API request body structs to get a field that can be
3271+
/// null (parsed as `None`) but is not optional. Will fail to parse if the key
3272+
/// is not present.
3273+
#[derive(Clone, Debug, Serialize)]
3274+
pub struct Nullable<T>(pub Option<T>);
3275+
3276+
impl<T> From<Option<T>> for Nullable<T> {
3277+
fn from(option: Option<T>) -> Self {
3278+
Nullable(option)
3279+
}
3280+
}
3281+
3282+
impl<T> Deref for Nullable<T> {
3283+
type Target = Option<T>;
3284+
3285+
fn deref(&self) -> &Self::Target {
3286+
&self.0
3287+
}
3288+
}
3289+
3290+
// it looks like we're just using Option's impl here, so why not derive instead?
3291+
// For some reason, deriving JsonSchema + #[serde(transparent)] doesn't work --
3292+
// it almost does, but the field does not end up marked required in the schema.
3293+
// There must be some special handling of Option somewhere causing it to be
3294+
// marked optional rather than nullable + required.
3295+
3296+
impl<T: JsonSchema> JsonSchema for Nullable<T> {
3297+
fn schema_name() -> String {
3298+
T::schema_name()
3299+
}
3300+
3301+
fn json_schema(
3302+
generator: &mut schemars::r#gen::SchemaGenerator,
3303+
) -> schemars::schema::Schema {
3304+
Option::<T>::json_schema(generator)
3305+
}
3306+
3307+
fn is_referenceable() -> bool {
3308+
Option::<T>::is_referenceable()
3309+
}
3310+
}
3311+
3312+
impl<'de, T: serde::Deserialize<'de>> serde::Deserialize<'de> for Nullable<T> {
3313+
fn deserialize<D: serde::Deserializer<'de>>(
3314+
deserializer: D,
3315+
) -> Result<Self, D::Error> {
3316+
// this is what errors if the key isn't present
3317+
let value = serde_json::Value::deserialize(deserializer)?;
3318+
3319+
use serde::de::Error;
3320+
Option::<T>::deserialize(value).map_err(D::Error::custom).map(Nullable)
3321+
}
3322+
}
3323+
32693324
#[cfg(test)]
32703325
mod test {
32713326
use serde::Deserialize;

nexus/db-model/src/silo_settings.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,18 @@ impl From<SiloSettings> for views::SiloSettings {
5050
#[derive(AsChangeset)]
5151
#[diesel(table_name = silo_settings)]
5252
pub struct SiloSettingsUpdate {
53-
pub device_token_max_ttl_seconds: Option<i64>,
53+
// Needs to be double Option so we can set a value of null in the DB by
54+
// passing Some(None). None by itself is ignored by Diesel.
55+
pub device_token_max_ttl_seconds: Option<Option<i64>>,
5456
pub time_modified: DateTime<Utc>,
5557
}
5658

5759
impl From<params::SiloSettingsUpdate> for SiloSettingsUpdate {
5860
fn from(params: params::SiloSettingsUpdate) -> Self {
5961
Self {
60-
device_token_max_ttl_seconds: params
61-
.device_token_max_ttl_seconds
62-
.map(|ttl| ttl.get().into()),
62+
device_token_max_ttl_seconds: Some(
63+
params.device_token_max_ttl_seconds.map(|ttl| ttl.get().into()),
64+
),
6365
time_modified: Utc::now(),
6466
}
6567
}

nexus/tests/integration_tests/device_auth.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,13 +278,34 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
278278
let msg = "unable to parse JSON body: device_token_max_ttl_seconds: invalid value";
279279
assert!(error.message.starts_with(&msg));
280280
}
281+
for value in [-3, 0] {
282+
let error = object_put_error(
283+
testctx,
284+
"/v1/settings",
285+
&serde_json::json!({ "device_token_max_ttl_seconds": value }),
286+
StatusCode::BAD_REQUEST,
287+
)
288+
.await;
289+
let msg = "unable to parse JSON body: device_token_max_ttl_seconds: invalid value";
290+
assert!(error.message.starts_with(&msg));
291+
}
292+
293+
// omitting the key is also a 400
294+
let error = object_put_error(
295+
testctx,
296+
"/v1/settings",
297+
&serde_json::json!({}),
298+
StatusCode::BAD_REQUEST,
299+
)
300+
.await;
301+
assert!(error.message.starts_with("unable to parse JSON body: missing field `device_token_max_ttl_seconds`"));
281302

282303
// set token expiration on silo to 3 seconds
283304
let settings: views::SiloSettings = object_put(
284305
testctx,
285306
"/v1/settings",
286307
&params::SiloSettingsUpdate {
287-
device_token_max_ttl_seconds: NonZeroU32::new(3),
308+
device_token_max_ttl_seconds: NonZeroU32::new(3).into(),
288309
},
289310
)
290311
.await;
@@ -316,6 +337,21 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
316337
project_list(&testctx, &initial_token, StatusCode::OK)
317338
.await
318339
.expect("initial token should still work");
340+
341+
// now test setting the silo max TTL back to null
342+
let settings: views::SiloSettings = object_put(
343+
testctx,
344+
"/v1/settings",
345+
&params::SiloSettingsUpdate {
346+
device_token_max_ttl_seconds: None.into(),
347+
},
348+
)
349+
.await;
350+
assert_eq!(settings.device_token_max_ttl_seconds, None);
351+
352+
let settings: views::SiloSettings =
353+
object_get(testctx, "/v1/settings").await;
354+
assert_eq!(settings.device_token_max_ttl_seconds, None);
319355
}
320356

321357
async fn project_list(

nexus/tests/integration_tests/endpoints.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use omicron_common::api::external::IdentityMetadataUpdateParams;
3434
use omicron_common::api::external::InstanceCpuCount;
3535
use omicron_common::api::external::Name;
3636
use omicron_common::api::external::NameOrId;
37+
use omicron_common::api::external::Nullable;
3738
use omicron_common::api::external::RouteDestination;
3839
use omicron_common::api::external::RouteTarget;
3940
use omicron_common::api::external::UserId;
@@ -1641,7 +1642,9 @@ pub static VERIFY_ENDPOINTS: LazyLock<Vec<VerifyEndpoint>> =
16411642
AllowedMethod::Get,
16421643
AllowedMethod::Put(
16431644
serde_json::to_value(&params::SiloSettingsUpdate {
1644-
device_token_max_ttl_seconds: NonZeroU32::new(3),
1645+
device_token_max_ttl_seconds: Nullable(
1646+
NonZeroU32::new(3),
1647+
),
16451648
})
16461649
.unwrap(),
16471650
),

nexus/types/src/external_api/params.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ use omicron_common::api::external::{
1313
AddressLotKind, AffinityPolicy, AllowedSourceIps, BfdMode, BgpPeer,
1414
ByteCount, FailureDomain, Hostname, IdentityMetadataCreateParams,
1515
IdentityMetadataUpdateParams, InstanceAutoRestartPolicy, InstanceCpuCount,
16-
LinkFec, LinkSpeed, Name, NameOrId, PaginationOrder, RouteDestination,
17-
RouteTarget, TxEqConfig, UserId,
16+
LinkFec, LinkSpeed, Name, NameOrId, Nullable, PaginationOrder,
17+
RouteDestination, RouteTarget, TxEqConfig, UserId,
1818
};
1919
use omicron_common::disk::DiskVariant;
2020
use oxnet::{IpNet, Ipv4Net, Ipv6Net};
@@ -491,12 +491,12 @@ pub struct SiloQuotasUpdate {
491491
// was discussed.
492492

493493
/// Updateable properties of a silo's settings.
494-
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
494+
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
495495
pub struct SiloSettingsUpdate {
496-
/// Maximum lifetime of a device token in seconds. If unset (or set to
497-
/// null), users will be able to create tokens that do not expire.
496+
/// Maximum lifetime of a device token in seconds. If set to null, users
497+
/// will be able to create tokens that do not expire.
498498
#[schemars(range(min = 1))]
499-
pub device_token_max_ttl_seconds: Option<NonZeroU32>,
499+
pub device_token_max_ttl_seconds: Nullable<NonZeroU32>,
500500
}
501501

502502
/// Create-time parameters for a `User`

openapi/nexus.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22926,12 +22926,15 @@
2292622926
"properties": {
2292722927
"device_token_max_ttl_seconds": {
2292822928
"nullable": true,
22929-
"description": "Maximum lifetime of a device token in seconds. If unset (or set to null), users will be able to create tokens that do not expire.",
22929+
"description": "Maximum lifetime of a device token in seconds. If set to null, users will be able to create tokens that do not expire.",
2293022930
"type": "integer",
2293122931
"format": "uint32",
2293222932
"minimum": 1
2293322933
}
22934-
}
22934+
},
22935+
"required": [
22936+
"device_token_max_ttl_seconds"
22937+
]
2293522938
},
2293622939
"SiloUtilization": {
2293722940
"description": "View of a silo's resource utilization and capacity",

0 commit comments

Comments
 (0)