Skip to content

feat(api): add DNSBL resource lifecycle - #124

Merged
seonghobae merged 1 commit into
feat/operator-resource-apifrom
feat/dnsbl-resource-api
Aug 26, 2026
Merged

feat(api): add DNSBL resource lifecycle#124
seonghobae merged 1 commit into
feat/operator-resource-apifrom
feat/dnsbl-resource-api

Conversation

@seonghobae

@seonghobae seonghobae commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add GET/PUT/DELETE lifecycle for individual DNSBL entries
  • enforce path/body identity, write-capable admin RBAC, and ETag/If-Match concurrency
  • preserve transactional persistence rollback and successful mutation audit evidence
  • document the contract in the API inventory and OpenAPI slice

Stack

Validation

  • cargo fmt --check
  • cargo test --locked --workspace (all library/workspace suites passed; the binary SIGTERM test passed on immediate focused rerun after one timing failure)
  • cargo test --locked dnsbl_item_api_enforces_identity_etag_rbac_and_audits_delete
  • cargo clippy --locked --workspace --all-targets -- -D warnings
  • git diff --check
  • OpenAPI YAML parse

Open in Devin Review

@seonghobae
seonghobae merged commit 85a4e43 into feat/operator-resource-api Aug 26, 2026
1 of 2 checks passed
@seonghobae
seonghobae deleted the feat/dnsbl-resource-api branch August 26, 2026 21:06
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9255cd9c-73a4-4301-a071-a99a69744f6d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Open in Devin Review

Comment thread docs/openapi.yaml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 No DnsblEntry schema in OpenAPI components

components.schemas defines Route but no DnsblEntry, so the DNSBL body $refs have no target. Completing the DNSBL contract requires adding a DnsblEntry schema (address, code, reason, source, ttl_seconds, optional prefix_len).

(Refers to this code)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread docs/openapi.yaml
Comment on lines +103 to +118
put:
summary: Create or conditionally replace one DNSBL entry
security: [{ AdminToken: [] }]
parameters:
- name: If-Match
in: header
required: false
schema: { type: string }
responses:
'200': { description: DNSBL entry replaced }
'201': { description: DNSBL entry created }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'412': { description: ETag does not match }
'428': { description: If-Match required for an existing entry }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 DNSBL PUT declares no request body

The put operation for /api/dnsbl/{address} declares no requestBody, unlike the route put at openapi.yaml. The handler requires a JSON DnsblEntry body, so a client generated from this contract sends no body and every create/replace fails.

Suggested change
put:
summary: Create or conditionally replace one DNSBL entry
security: [{ AdminToken: [] }]
parameters:
- name: If-Match
in: header
required: false
schema: { type: string }
responses:
'200': { description: DNSBL entry replaced }
'201': { description: DNSBL entry created }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'412': { description: ETag does not match }
'428': { description: If-Match required for an existing entry }
put:
summary: Create or conditionally replace one DNSBL entry
security: [{ AdminToken: [] }]
parameters:
- name: If-Match
in: header
required: false
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/DnsblEntry' }
responses:
'200': { description: DNSBL entry replaced }
'201': { description: DNSBL entry created }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'412': { description: ETag does not match }
'428': { description: If-Match required for an existing entry }
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread docs/openapi.yaml
Comment on lines +98 to +101
'200':
description: DNSBL entry
headers:
ETag: { schema: { type: string } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 DNSBL GET response omits its JSON body

The 200 response for GET /api/dnsbl/{address} documents only the ETag header and no content, unlike the route GET at openapi.yaml. The handler returns a JSON entry body, so the contract understates the actual response shape.

Suggested change
'200':
description: DNSBL entry
headers:
ETag: { schema: { type: string } }
'200':
description: DNSBL entry
headers:
ETag: { schema: { type: string } }
content:
application/json:
schema: { $ref: '#/components/schemas/DnsblEntry' }
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread src/lib.rs
Comment on lines +1092 to +1216
async fn get_dnsbl(
State(state): State<AppState>,
PathParam(address): PathParam<IpAddr>,
) -> Response {
let data = state.inner.read().await;
let Some(entry) = data.dnsbl.iter().find(|entry| entry.address == address) else {
return error(StatusCode::NOT_FOUND, "DNSBL entry not found");
};
dnsbl_response(StatusCode::OK, entry)
}

async fn replace_dnsbl(
State(state): State<AppState>,
PathParam(address): PathParam<IpAddr>,
headers: HeaderMap,
Json(entry): Json<DnsblEntry>,
) -> Response {
if let Some(response) = management_write_denied(&state, &headers) {
return response;
}
if entry.address != address {
return error(
StatusCode::BAD_REQUEST,
"path address must match body address",
);
}
if let Err(message) = validate_dnsbl(&entry) {
return error(StatusCode::BAD_REQUEST, message);
}
let expected = headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok());
let actor = audit_actor(&state, &headers);
match state
.try_mutate_and_persist(|data| {
let existing = data.dnsbl.iter().find(|item| item.address == address);
let existed = existing.is_some();
match existing {
None if expected.is_some() => Err((
StatusCode::PRECONDITION_FAILED,
"If-Match requires an existing DNSBL entry".to_string(),
)),
Some(_) if expected.is_none() => Err((
StatusCode::PRECONDITION_REQUIRED,
"If-Match is required when replacing an existing DNSBL entry".to_string(),
)),
Some(current) if !if_match_satisfied(expected, &dnsbl_etag(current)) => Err((
StatusCode::PRECONDITION_FAILED,
"DNSBL entry changed; GET the latest representation and retry".to_string(),
)),
_ => {
let status = if existed {
StatusCode::OK
} else {
StatusCode::CREATED
};
let saved = upsert_dnsbl(&mut data.dnsbl, entry.clone());
record_successful_audit_log(
data,
actor,
"replace_dnsbl",
"dnsbl_entry",
saved.address.to_string(),
);
Ok((status, saved))
}
}
})
.await
{
Ok(Ok((status, saved))) => dnsbl_response(status, &saved),
Ok(Err((status, message))) => error(status, message),
Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
}
}

async fn delete_dnsbl(
State(state): State<AppState>,
PathParam(address): PathParam<IpAddr>,
headers: HeaderMap,
) -> Response {
if let Some(response) = management_write_denied(&state, &headers) {
return response;
}
let expected = headers
.get(header::IF_MATCH)
.and_then(|value| value.to_str().ok());
if expected.is_none() {
return error(
StatusCode::PRECONDITION_REQUIRED,
"If-Match is required when deleting a DNSBL entry",
);
}
let actor = audit_actor(&state, &headers);
match state
.try_mutate_and_persist(|data| {
let Some(index) = data.dnsbl.iter().position(|entry| entry.address == address) else {
return Err((
StatusCode::PRECONDITION_FAILED,
"If-Match requires an existing DNSBL entry".to_string(),
));
};
if !if_match_satisfied(expected, &dnsbl_etag(&data.dnsbl[index])) {
return Err((
StatusCode::PRECONDITION_FAILED,
"DNSBL entry changed; GET the latest representation and retry".to_string(),
));
}
data.dnsbl.remove(index);
record_successful_audit_log(
data,
actor,
"delete_dnsbl",
"dnsbl_entry",
address.to_string(),
);
Ok(())
})
.await
{
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
Ok(Err((status, message))) => error(status, message),
Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, message),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: DNSBL item handlers mirror route lifecycle

get_dnsbl, replace_dnsbl, and delete_dnsbl replicate the route lifecycle: write gate, path/body identity check, validation, If-Match precondition arms, transactional persist-with-rollback, and audit logging. ETag helpers match route_etag/route_response. Address equality uses parsed IpAddr, so IPv6 canonicalization is handled. No logic divergence found.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant