diff --git a/.github/workflows/main_function-app-traceability-api.yml b/.github/workflows/main_function-app-traceability-api.yml index b4eba60..7605f97 100644 --- a/.github/workflows/main_function-app-traceability-api.yml +++ b/.github/workflows/main_function-app-traceability-api.yml @@ -7,6 +7,12 @@ on: push: branches: - main + pull_request: + branches: + - main + paths-ignore: + - 'documentation/**' + - '*.md' workflow_dispatch: env: @@ -14,11 +20,10 @@ env: DOTNET_VERSION: '8.0.x' # set this to the dotnet version to use jobs: - build-and-deploy: + build: runs-on: windows-latest permissions: - id-token: write #This is required for requesting the JWT - contents: read #This is required for actions/checkout + contents: read steps: - name: 'Checkout GitHub Action' @@ -35,6 +40,30 @@ jobs: pushd './${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}' dotnet build --configuration Release --output ./output popd + + - name: 'Upload build artifacts' + uses: actions/upload-artifact@v4 + with: + name: build-output + path: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}/output + + deploy: + runs-on: windows-latest + needs: build + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + permissions: + id-token: write #This is required for requesting the JWT + contents: read #This is required for actions/checkout + + steps: + - name: 'Checkout GitHub Action' + uses: actions/checkout@v4 + + - name: 'Download build artifacts' + uses: actions/download-artifact@v4 + with: + name: build-output + path: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }}/output - name: Login to Azure uses: azure/login@v2 diff --git a/README.md b/README.md index 996c36b..7f41f00 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,35 @@ ## Scope This Function application implements the AgGateway Traceability API particularly focused on - Traceability Resource Units (TRU) which represents the quantity and quality measurements of an item in a container at a point in time -- Critical Tracking Events (CTE) particularly focused on the new Transfer Event where a TRU is transferred from one container into another container using a device resource, and potential having a remaining amount. Other CTEs are historically supported including the Transport Event, Change of Custoday Event and the Change of Ownership Event. Another new event is the Identification Event, which is the act of identification and provision of a unique identifier to record the event, item, device, container, etc. +- Critical Tracking Events (CTE) particularly focused on the new Transfer Event where a TRU is transferred from one container into another container using a device resource, and potential having a remaining amount. Other CTEs are historically supported including the Transport Event, Change of Custody Event and the Change of Ownership Event. Another new event is the Identification Event, which is the act of identification and provision of a unique identifier to record the event, item, device, container, etc. - Key Data Elements including the shipment id, dock door id, trailer id, container id, field id, farmer id, farmer id, GLN, GTIN for product, etc. KDEs are most often identifiers and not measurements or observations. They help with the linkage of TRUs and CTEs. ## Endpoints The endpoints are defined by the OpenAPI specification generated from the NIST Score tool, and developed by the AgGateway Traceability Work Group. ### Critical Tracking Event -POST /traceability/V1/critical-tracking-event -POST /traceability/V1/critical-tracking-event-list -GET /traceability/V1/critical-tracking-event-list -GET /traceability/V1/critical-tracking-event/{id} -PATCH /traceability/V1/critical-tracking-event/{id} +POST /traceability/V2/critical-tracking-event +POST /traceability/V2/critical-tracking-event-list +GET /traceability/V2/critical-tracking-event-list +GET /traceability/V2/critical-tracking-event/{id} +PATCH /traceability/V2/critical-tracking-event/{id} ### Field Operations -POST /traceability/V1/operation -PATCH /traceability/V1/operation/{id} -POST /traceability/V1/operator-party -GET /traceability/V1/container/{id} -GET /traceability/V1/container-list -GET /traceability/V1/device-resource-list -GET /traceability/V1/field/{id} -GET /traceability/V1/field-list +POST /traceability/V2/operation +PATCH /traceability/V2/operation/{id} +POST /traceability/V2/operator-party +GET /traceability/V2/container/{id} +GET /traceability/V2/container-list +GET /traceability/V2/device-resource-list +GET /traceability/V2/field/{id} +GET /traceability/V2/field-list ### Traceable Resource Unit -POST /traceability/V1/traceable-resource-unit -GET /traceability/V1/traceable-resource-unit/{id} -PATCH /traceability/V1/traceable-resource-unit/{id} -GET /traceability/V1/traceable-resource-unit-list -PATCH /traceability/traceable-resource-unit/{id}/V1/container-state - -# Co-Pilot Prompt -Complete this function app by adding an HttpTrigger implementation of all the endpoints specified in the OpenAPI yml file in this project, ensuring that parameters are properly handled as specified with the endpoint. Each JSON payload in either the request body or response will reference the appropriate 'type' specified in the Model folder as related to the resource definition in the endpoint path, with a conversion from the '-' dash notation to the UpperCamelCase notation defined in the Model. All JSON will be persisted in the Cosmos DB. All PATCH operations will implement the upsert pattern, by retrieving the JSON by id or uuid, applying the Newtonsoft JSONPatch method already created in the helper class in this project, then replacing the JSON in the Cosmos DB container. - -Sure, but use the existing workspace, the existing CosmosNoSQLAdapter.cs capabilities rewriting if necessary, and retain the OpenAPI yml file in the existing yml folder. Ensure the isolidate worker model is used, as in the existing function-app-traceability-api.cs files. Remove the function-app-traceability-api.cs file after migrating to CriticalTrackingEventListFunction.cs - - -Define the next steps to add new Agentic capabilities to the traceability capabilities defined in this OpenAPI yml. Determine if the exist function app can be extended (ideal) or whether it is necessary to create a new agent app. Ensure the the necessary MCP server protocols are able to interoperate with the OpenAPI specification in this yml. Define a mapping from the OpenAPI yml description keywords to that needed in the agent registry. \ No newline at end of file +POST /traceability/V2/traceable-resource-unit +GET /traceability/V2/traceable-resource-unit/{id} +PATCH /traceability/V2/traceable-resource-unit/{id} +GET /traceability/V2/traceable-resource-unit-list +PATCH /traceability/V2/traceable-resource-unit/{id}/container-state + +## Planting Operations Scenario +The planting operations sequence diagram shows which Traceability API V2 endpoints are used for this scenario. This involves logging into an application, loading the planter box from a seed tender (transfer event of TRUs), planting the seed into the field (transfer event of TRUs), and iterating until the field is completely. There could also be another scenario focused on positioning the seed tender properly for the refilling operations, knowing the points on the map wehere this occurred (transport event of the TRUs). In these scenarios, the container will likely have a remaining amount as it is not convenient to move the seed tender to the middle of the field. diff --git a/documentation/AgGateway_Traceability_Planting_Operations_v2.png b/documentation/AgGateway_Traceability_Planting_Operations_v2.png new file mode 100644 index 0000000..1299078 Binary files /dev/null and b/documentation/AgGateway_Traceability_Planting_Operations_v2.png differ diff --git a/documentation/AgGateway_Traceability_Planting_Operations_v2.txt b/documentation/AgGateway_Traceability_Planting_Operations_v2.txt index b789513..ed5769f 100644 --- a/documentation/AgGateway_Traceability_Planting_Operations_v2.txt +++ b/documentation/AgGateway_Traceability_Planting_Operations_v2.txt @@ -13,8 +13,8 @@ database DataPlatform == log in == Farmer->MobileApp: log in -MobileApp -> MobileLocalAPI: POST /traceability/V2/operator-party MobileApp -> DataPlatform: authenticate +MobileApp -> MobileLocalAPI: POST /traceability/V2/operator-party == field selection; on-line == Farmer->MobileApp: retrieve my fields diff --git a/src/Functions/ContainerFunction.cs b/src/Functions/ContainerFunction.cs index f1f2f74..e7e2e6a 100644 --- a/src/Functions/ContainerFunction.cs +++ b/src/Functions/ContainerFunction.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using IO.Swagger.Models; using TraceabilityAPI.Cosmos.IO; +using TraceabilityAPI.Services; using Microsoft.Azure.Cosmos; using System.Net; @@ -34,9 +35,7 @@ public async Task GetById( var item = await adapter.ReadDocument(id, id, _logger); if (item == null) { - var notFound = req.CreateResponse(HttpStatusCode.NotFound); - await notFound.WriteStringAsync($"Item {id} not found."); - return notFound; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.NotFound, "Not Found", $"Item {id} not found.", null); } var ok = req.CreateResponse(HttpStatusCode.OK); @@ -45,16 +44,12 @@ public async Task GetById( } catch (Microsoft.Azure.Cosmos.CosmosException ce) when (ce.StatusCode == HttpStatusCode.NotFound) { - var notFound = req.CreateResponse(HttpStatusCode.NotFound); - await notFound.WriteStringAsync($"Item {id} not found."); - return notFound; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.NotFound, "Not Found", $"Item {id} not found.", null); } catch (System.Exception ex) { _logger.LogError(ex, "Error reading container"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Error reading container."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error reading container.", null); } } @@ -75,9 +70,7 @@ public async Task GetList( catch (System.Exception ex) { _logger.LogError(ex, "Error querying containers"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Error retrieving container list."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error retrieving container list.", null); } } } \ No newline at end of file diff --git a/src/Functions/CriticalTrackingEventByIdFunction.cs b/src/Functions/CriticalTrackingEventByIdFunction.cs index 9028387..eff2210 100644 --- a/src/Functions/CriticalTrackingEventByIdFunction.cs +++ b/src/Functions/CriticalTrackingEventByIdFunction.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json.Linq; using IO.Swagger.Models; using TraceabilityAPI.Cosmos.IO; +using TraceabilityAPI.Services; using Microsoft.Azure.Cosmos; using System.Net; @@ -45,16 +46,12 @@ public async Task GetById( catch (System.Exception ex) { _logger.LogError(ex, "Error reading document"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Error reading document."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error reading document.", null); } if (item == null) { - var notFound = req.CreateResponse(HttpStatusCode.NotFound); - await notFound.WriteStringAsync($"Item {id} not found."); - return notFound; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.NotFound, "Not Found", $"Item {id} not found.", null); } var ok = req.CreateResponse(HttpStatusCode.OK); @@ -70,9 +67,7 @@ public async Task PatchById( string body = await new StreamReader(req.Body).ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Request body is required."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Request body is required.", null); } JObject patchPayload; @@ -82,9 +77,7 @@ public async Task PatchById( } catch (JsonException) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Invalid JSON payload."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid JSON payload.", null); } CriticalTrackingEventList existing = null; @@ -99,9 +92,7 @@ public async Task PatchById( catch (System.Exception ex) { _logger.LogError(ex, "Error reading document for patch"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Error reading existing document."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error reading existing document.", null); } CriticalTrackingEventList toUpsert; @@ -121,17 +112,13 @@ public async Task PatchById( if (status != HttpStatusCode.Created && status != HttpStatusCode.OK && status != HttpStatusCode.NoContent) { _logger.LogError("Upsert returned non-success status: {Status}", status); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist patched object."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist patched object.", null); } } catch (System.Exception ex) { _logger.LogError(ex, "Failed to upsert patched object"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist patched object."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist patched object.", null); } var resp = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Functions/CriticalTrackingEventFunction.cs b/src/Functions/CriticalTrackingEventFunction.cs index f328dec..4f919ff 100644 --- a/src/Functions/CriticalTrackingEventFunction.cs +++ b/src/Functions/CriticalTrackingEventFunction.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json.Linq; using IO.Swagger.Models; using TraceabilityAPI.Cosmos.IO; +using TraceabilityAPI.Services; using Microsoft.Azure.Cosmos; using System.Net; @@ -32,9 +33,7 @@ public async Task PostCriticalTrackingEvent( var body = await new StreamReader(req.Body).ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Request body is required."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Request body is required.", null); } CriticalTrackingEventList? payload; @@ -45,16 +44,12 @@ public async Task PostCriticalTrackingEvent( catch (JsonException ex) { _logger.LogWarning(ex, "Payload deserialization failed"); - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Invalid JSON payload."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid JSON payload.", null); } if (payload is null) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Invalid payload for CriticalTrackingEventList."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid payload for CriticalTrackingEventList.", null); } var containerName = Environment.GetEnvironmentVariable("COSMOS_CONTAINER_CTE") ?? "critical-tracking-event"; @@ -67,17 +62,13 @@ public async Task PostCriticalTrackingEvent( if (status != HttpStatusCode.Created && status != HttpStatusCode.OK && status != HttpStatusCode.NoContent) { _logger.LogError("Cosmos write returned non-success status: {Status}", status); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist payload."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist payload.", null); } } catch (System.Exception ex) { _logger.LogError(ex, "Failed to persist critical tracking event"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist payload."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist payload.", null); } var response = req.CreateResponse(HttpStatusCode.Created); @@ -111,9 +102,7 @@ public async Task PostCriticalTrackingEventList( if (payload == null) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Invalid payload for CriticalTrackingEventList."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid payload for CriticalTrackingEventList.", null); } var containerName = Environment.GetEnvironmentVariable("COSMOS_CONTAINER_CTE_LIST") ?? "critical-tracking-event-list"; @@ -125,17 +114,13 @@ public async Task PostCriticalTrackingEventList( if (status != HttpStatusCode.Created && status != HttpStatusCode.OK && status != HttpStatusCode.NoContent) { _logger.LogError("Cosmos write returned non-success status: {Status}", status); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist payload."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist payload.", null); } } catch (System.Exception ex) { _logger.LogError(ex, "Failed to persist critical tracking event list"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist payload."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist payload.", null); } var response = req.CreateResponse(HttpStatusCode.Created); diff --git a/src/Functions/CriticalTrackingEventListFunction.cs b/src/Functions/CriticalTrackingEventListFunction.cs index 9aa51b4..7d3a0cb 100644 --- a/src/Functions/CriticalTrackingEventListFunction.cs +++ b/src/Functions/CriticalTrackingEventListFunction.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using IO.Swagger.Models; using TraceabilityAPI.Cosmos.IO; +using TraceabilityAPI.Services; using Microsoft.Azure.Cosmos; using System.Net; @@ -38,9 +39,7 @@ public async Task GetList( catch (System.Exception ex) { _logger.LogError(ex, "Error querying critical tracking event list"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Error retrieving list."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error retrieving list.", null); } } } \ No newline at end of file diff --git a/src/Functions/DeviceResourceFunction.cs b/src/Functions/DeviceResourceFunction.cs index b376fac..ec9ecd8 100644 --- a/src/Functions/DeviceResourceFunction.cs +++ b/src/Functions/DeviceResourceFunction.cs @@ -5,6 +5,7 @@ using Newtonsoft.Json; using IO.Swagger.Models; using TraceabilityAPI.Cosmos.IO; +using TraceabilityAPI.Services; using Microsoft.Azure.Cosmos; using System.Net; @@ -38,9 +39,7 @@ public async Task GetList( catch (System.Exception ex) { _logger.LogError(ex, "Error querying device resources"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Error retrieving device resource list."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error retrieving device resource list.", null); } } } \ No newline at end of file diff --git a/src/Functions/OperationFunction.cs b/src/Functions/OperationFunction.cs index 289e4a9..0f49470 100644 --- a/src/Functions/OperationFunction.cs +++ b/src/Functions/OperationFunction.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json.Linq; using IO.Swagger.Models; using TraceabilityAPI.Cosmos.IO; +using TraceabilityAPI.Services; using Microsoft.Azure.Cosmos; using System.Net; @@ -30,25 +31,19 @@ public async Task PostOperation( var body = await new StreamReader(req.Body).ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Request body is required."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Request body is required.", null); } OperationRequestCreate? payload; try { payload = JsonConvert.DeserializeObject(body); } catch (JsonException) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Invalid JSON payload."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid JSON payload.", null); } if (payload == null) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Invalid payload for Operation."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid payload for Operation.", null); } var container = System.Environment.GetEnvironmentVariable("COSMOS_CONTAINER_OPERATION") ?? "operation"; @@ -59,17 +54,13 @@ public async Task PostOperation( var status = await adapter.WriteDocument(payload, _logger); if (status != HttpStatusCode.Created && status != HttpStatusCode.OK && status != HttpStatusCode.NoContent) { - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist operation."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist operation.", null); } } catch (System.Exception ex) { _logger.LogError(ex, "Failed to persist operation"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist operation."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist operation.", null); } var resp = req.CreateResponse(HttpStatusCode.Created); @@ -85,9 +76,7 @@ public async Task PatchOperation( string body = await new StreamReader(req.Body).ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Request body is required."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Request body is required.", null); } JObject patchPayload; @@ -97,9 +86,7 @@ public async Task PatchOperation( } catch (JsonException) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Invalid JSON payload."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid JSON payload.", null); } var container = System.Environment.GetEnvironmentVariable("COSMOS_CONTAINER_OPERATION") ?? "operation"; @@ -117,9 +104,7 @@ public async Task PatchOperation( catch (System.Exception ex) { _logger.LogError(ex, "Error reading operation for patch"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Error reading existing operation."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error reading existing operation.", null); } IO.Swagger.Models.OperationResponseUpdate toUpsert; @@ -139,17 +124,13 @@ public async Task PatchOperation( if (status != HttpStatusCode.Created && status != HttpStatusCode.OK && status != HttpStatusCode.NoContent) { _logger.LogError("Upsert returned non-success status: {Status}", status); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist patched operation."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist patched operation.", null); } } catch (System.Exception ex) { _logger.LogError(ex, "Failed to upsert patched operation"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist patched operation."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist patched operation.", null); } var resp = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Functions/OperatorPartyFunction.cs b/src/Functions/OperatorPartyFunction.cs index ae90458..8736b97 100644 --- a/src/Functions/OperatorPartyFunction.cs +++ b/src/Functions/OperatorPartyFunction.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json; using IO.Swagger.Models; using TraceabilityAPI.Cosmos.IO; +using TraceabilityAPI.Services; using Microsoft.Azure.Cosmos; using System.Net; @@ -29,25 +30,19 @@ public async Task PostOperatorParty( var body = await new StreamReader(req.Body).ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Request body is required."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Request body is required.", null); } OperatorParty? payload; try { payload = JsonConvert.DeserializeObject(body); } catch (JsonException) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Invalid JSON payload."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid JSON payload.", null); } if (payload == null) { - var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteStringAsync("Invalid payload for OperatorParty."); - return bad; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid payload for OperatorParty.", null); } var container = System.Environment.GetEnvironmentVariable("COSMOS_CONTAINER_OPERATOR") ?? "operator-party"; @@ -58,17 +53,13 @@ public async Task PostOperatorParty( var status = await adapter.WriteDocument(payload, _logger); if (status != HttpStatusCode.Created && status != HttpStatusCode.OK && status != HttpStatusCode.NoContent) { - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist operator party."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist operator party.", null); } } catch (System.Exception ex) { _logger.LogError(ex, "Failed to persist operator party"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteStringAsync("Failed to persist operator party."); - return err; + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist operator party.", null); } var resp = req.CreateResponse(HttpStatusCode.Created); diff --git a/src/Functions/TraceableResourceUnitFunction.cs b/src/Functions/TraceableResourceUnitFunction.cs new file mode 100644 index 0000000..e2cb68c --- /dev/null +++ b/src/Functions/TraceableResourceUnitFunction.cs @@ -0,0 +1,325 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using IO.Swagger.Models; +using TraceabilityAPI.Cosmos.IO; +using TraceabilityAPI.Services; +using Microsoft.Azure.Cosmos; +using System.Net; + +public class TraceableResourceUnitFunction +{ + private readonly ILogger _logger; + private readonly CosmosClient _cosmosClient; + private readonly string _database; + + public TraceableResourceUnitFunction(ILoggerFactory loggerFactory, CosmosClient cosmosClient) + { + _logger = loggerFactory.CreateLogger(); + _cosmosClient = cosmosClient ?? throw new ArgumentNullException(nameof(cosmosClient)); + _database = System.Environment.GetEnvironmentVariable("COSMOS_DB") ?? "traceability-db"; + } + + [Function("CreateTraceableResourceUnit")] + public async Task PostTraceableResourceUnit( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "traceability/V2/traceable-resource-unit")] HttpRequestData req) + { + var body = await new StreamReader(req.Body).ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteStringAsync("Request body is required."); + return bad; + } + + JObject payload; + try + { + payload = JObject.Parse(body); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Payload deserialization failed"); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid JSON payload.", null); + } + + if (payload == null) + { + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid payload for Traceable Resource Unit.", null); + } + + var container = System.Environment.GetEnvironmentVariable("COSMOS_CONTAINER_TRU") ?? "traceable-resource-unit"; + var adapter = new CosmosNoSQLAdapter(_cosmosClient, _database, container); + + try + { + string id = payload["id"]?.ToString() ?? Guid.NewGuid().ToString(); + + // Check for duplicate TRU using ID + try + { + var existingTru = await adapter.ReadDocument(id, id, _logger); + if (existingTru != null) + { + _logger.LogWarning("Duplicate TRU detected: {Id}", id); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.Conflict, "Conflict", "Traceable Resource Unit with this ID already exists.", null); + } + } + catch (Microsoft.Azure.Cosmos.CosmosException) + { + // Ignore not found + } + + var status = await adapter.WriteDocument(payload, id, _logger); + if (status != HttpStatusCode.Created && status != HttpStatusCode.OK && status != HttpStatusCode.NoContent) + { + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist traceable resource unit.", null); + } + + // Return the created object with ID + payload["id"] = id; + var resp = req.CreateResponse(HttpStatusCode.Created); + await resp.WriteStringAsync(payload.ToString()); + return resp; + } + catch (Microsoft.Azure.Cosmos.CosmosException ce) when (ce.StatusCode == HttpStatusCode.TooManyRequests) + { + _logger.LogWarning(ce, "Cosmos DB throttling detected"); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.ServiceUnavailable, "Service Unavailable", "Service temporarily unavailable. Please retry after a delay.", null); + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Failed to persist traceable resource unit"); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist traceable resource unit.", null); + } + } + + [Function("GetTraceableResourceUnitById")] + public async Task GetById( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "traceability/V2/traceable-resource-unit/{id}")] HttpRequestData req, + string id) + { + var container = System.Environment.GetEnvironmentVariable("COSMOS_CONTAINER_TRU") ?? "traceable-resource-unit"; + var adapter = new CosmosNoSQLAdapter(_cosmosClient, _database, container); + + try + { + var item = await adapter.ReadDocument(id, id, _logger); + if (item == null) + { + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.NotFound, "Not Found", $"Traceable Resource Unit {id} not found.", null); + } + + var ok = req.CreateResponse(HttpStatusCode.OK); + await ok.WriteStringAsync(item.ToString()); + return ok; + } + catch (CosmosException ce) when (ce.StatusCode == HttpStatusCode.NotFound) + { + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.NotFound, "Not Found", $"Traceable Resource Unit {id} not found.", null); + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error reading traceable resource unit"); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to retrieve traceable resource unit.", null); + } + } + + [Function("UpdateTraceableResourceUnitById")] + public async Task PatchById( + [HttpTrigger(AuthorizationLevel.Function, "patch", Route = "traceability/V2/traceable-resource-unit/{id}")] HttpRequestData req, + string id) + { + var body = await new StreamReader(req.Body).ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) + { + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Request body is required.", null); + } + + JObject patchPayload; + try + { + patchPayload = JObject.Parse(body); + } + catch (JsonException) + { + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid JSON payload.", null); + } + + var container = System.Environment.GetEnvironmentVariable("COSMOS_CONTAINER_TRU") ?? "traceable-resource-unit"; + var adapter = new CosmosNoSQLAdapter(_cosmosClient, _database, container); + + JObject existing = null; + try + { + existing = await adapter.ReadDocument(id, id, _logger); + } + catch (CosmosException ce) when (ce.StatusCode == HttpStatusCode.NotFound) + { + existing = null; + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error reading traceable resource unit for patch"); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error reading existing traceable resource unit.", null); + } + + JObject toUpsert; + if (existing == null) + { + toUpsert = new JObject(); + toUpsert = JSONPatchHelper.PatchObject(toUpsert, patchPayload, "", _logger); + } + else + { + toUpsert = JSONPatchHelper.PatchObject(existing, patchPayload, "", _logger); + } + + try + { + var status = await adapter.WriteDocument(toUpsert, id, _logger); + if (status != HttpStatusCode.Created && status != HttpStatusCode.OK && status != HttpStatusCode.NoContent) + { + _logger.LogError("Upsert returned non-success status: {Status}", status); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist patched traceable resource unit.", null); + } + + var resp = req.CreateResponse(HttpStatusCode.OK); + await resp.WriteStringAsync(toUpsert.ToString()); + return resp; + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Failed to upsert patched traceable resource unit"); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to persist patched traceable resource unit.", null); + } + } + + [Function("GetTraceableResourceUnitList")] + public async Task GetList( + [HttpTrigger(AuthorizationLevel.Function, "get", Route = "traceability/V2/traceable-resource-unit-list")] HttpRequestData req) + { + var container = System.Environment.GetEnvironmentVariable("COSMOS_CONTAINER_TRU") ?? "traceable-resource-unit"; + var adapter = new CosmosNoSQLAdapter(_cosmosClient, _database, container); + + try + { + var queryParams = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var sinceLastDateTime = queryParams["sinceLastDateTime"]; + var greaterThanDateTime = queryParams["greaterThanDateTime"]; + var lessThanDateTime = queryParams["lessThanDateTime"]; + var farmerUUID = queryParams["farmerUUID"]; + var fieldUUID = queryParams["fieldUUID"]; + var deviceUID = queryParams["deviceUID"]; + var containerUUID = queryParams["containerUUID"]; + + string query = "SELECT * FROM c WHERE 1=1"; + + if (!string.IsNullOrWhiteSpace(greaterThanDateTime)) + { + query += $" AND c._ts > {UnixTimeStampFromDateTime(DateTime.Parse(greaterThanDateTime))}"; + } + if (!string.IsNullOrWhiteSpace(lessThanDateTime)) + { + query += $" AND c._ts < {UnixTimeStampFromDateTime(System.DateTime.Parse(lessThanDateTime))}"; + } + if (!string.IsNullOrWhiteSpace(farmerUUID)) + { + query += $" AND c.partyId = '{farmerUUID}'"; + } + if (!string.IsNullOrWhiteSpace(containerUUID)) + { + query += $" AND c.container.uuid = '{containerUUID}'"; + } + + var list = await adapter.QueryDocuments(query, _logger); + var resp = req.CreateResponse(HttpStatusCode.OK); + await resp.WriteStringAsync(JsonConvert.SerializeObject(list)); + return resp; + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error querying traceable resource unit list"); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error retrieving traceable resource unit list.", null); + } + } + + [Function("UpdateContainerState")] + public async Task UpdateContainerState( + [HttpTrigger(AuthorizationLevel.Function, "patch", Route = "traceability/V2/traceable-resource-unit/{id}/container-state")] HttpRequestData req, + string id) + { + var body = await new StreamReader(req.Body).ReadToEndAsync(); + if (string.IsNullOrWhiteSpace(body)) + { + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Request body is required.", null); + } + + JObject containerStatePayload; + try + { + containerStatePayload = JObject.Parse(body); + } + catch (JsonException) + { + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.BadRequest, "Bad Request", "Invalid JSON payload.", null); + } + + var container = System.Environment.GetEnvironmentVariable("COSMOS_CONTAINER_TRU") ?? "traceable-resource-unit"; + var adapter = new CosmosNoSQLAdapter(_cosmosClient, _database, container); + + dynamic existing = null; + try + { + existing = await adapter.ReadDocument(id, id, _logger); + } + catch (CosmosException ce) when (ce.StatusCode == HttpStatusCode.NotFound) + { + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.NotFound, "Not Found", $"Traceable Resource Unit {id} not found.", null); + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Error reading traceable resource unit for container state update"); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Error reading existing traceable resource unit.", null); + } + + try + { + // Patch the container state specifically + var patchPayload = new JObject(); + var containerObj = new JObject(); + containerObj["containerState"] = containerStatePayload; + patchPayload["container"] = containerObj; + + JObject toUpsert = JSONPatchHelper.PatchObject(existing, patchPayload, "", _logger); + + var status = await adapter.WriteDocument(toUpsert, id, _logger); + if (status != HttpStatusCode.Created && status != HttpStatusCode.OK && status != HttpStatusCode.NoContent) + { + _logger.LogError("Upsert returned non-success status: {Status}", status); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to update container state.", null); + } + + var resp = req.CreateResponse(HttpStatusCode.OK); + await resp.WriteStringAsync(JsonConvert.SerializeObject(containerStatePayload)); + return resp; + } + catch (System.Exception ex) + { + _logger.LogError(ex, "Failed to update container state"); + return await RfcProblemDetailsHelper.CreateProblemResponse(req, HttpStatusCode.InternalServerError, "Internal Server Error", "Failed to update container state.", null); + } + } + + private long UnixTimeStampFromDateTime(System.DateTime dateTime) + { + long unixTimestamp = ((System.DateTimeOffset)dateTime).ToUnixTimeSeconds(); + return unixTimestamp; + } +} diff --git a/src/Program.cs b/src/Program.cs index 8d0fe73..4970319 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -16,6 +16,20 @@ services.AddSingleton(new CosmosClient(conn)); + // Register RFC 9457 ProblemDetails support (ASP.NET Core 8.0+) + // This enables automatic Problem Details generation for errors with the correct media type + services.AddProblemDetails(options => + { + options.CustomizeProblemDetails = ctx => + { + // Include trace ID for debugging and correlation + if (!ctx.ProblemDetails.Extensions.ContainsKey("traceId")) + { + ctx.ProblemDetails.Extensions.Add("traceId", ctx.HttpContext.TraceIdentifier); + } + }; + }); + // register other shared services if needed }) .ConfigureLogging(logging => logging.AddConsole()) diff --git a/src/Services/RfcProblemDetailsHelper.cs b/src/Services/RfcProblemDetailsHelper.cs new file mode 100644 index 0000000..0b009d5 --- /dev/null +++ b/src/Services/RfcProblemDetailsHelper.cs @@ -0,0 +1,74 @@ +using System; +using System.Net; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker.Http; +using Newtonsoft.Json.Linq; + +namespace TraceabilityAPI.Services +{ + /// + /// Helper class for creating RFC 9457 ProblemDetails responses + /// Reference: https://tools.ietf.org/html/rfc9457 + /// + public static class RfcProblemDetailsHelper + { + /// + /// Creates an RFC 9457 ProblemDetails response for error scenarios + /// + /// The HTTP request + /// The HTTP status code + /// Short, human-readable summary of the problem + /// Human-readable explanation specific to this occurrence + /// URI identifying the specific occurrence (e.g., resource path or ID) + /// HTTP response with RFC 9457 formatted error + public static async Task CreateProblemResponse( + HttpRequestData req, + HttpStatusCode statusCode, + string title, + string detail, + string? instance) + { + var response = req.CreateResponse(statusCode); + response.Headers.Add("Content-Type", "application/problem+json"); + + // Generate a correlation ID from the request headers or create a new one + var traceId = req.Headers.TryGetValues("X-Correlation-ID", out var values) && values.Any() + ? values.First() + : Guid.NewGuid().ToString(); + + var problemDetails = new + { + type = GetProblemType(statusCode), + title = title, + status = (int)statusCode, + detail = detail, + instance = instance, + traceId = traceId, + timestamp = DateTime.UtcNow + }; + + await response.WriteAsJsonAsync(problemDetails); + return response; + } + + /// + /// Maps HTTP status codes to RFC 9110 type URIs + /// + private static string GetProblemType(HttpStatusCode statusCode) + { + return (int)statusCode switch + { + 400 => "https://httpwg.org/specs/rfc9110.html#status.400", + 401 => "https://httpwg.org/specs/rfc9110.html#status.401", + 403 => "https://httpwg.org/specs/rfc9110.html#status.403", + 404 => "https://httpwg.org/specs/rfc9110.html#status.404", + 409 => "https://httpwg.org/specs/rfc9110.html#status.409", + 429 => "https://httpwg.org/specs/rfc9110.html#status.429", + 500 => "https://httpwg.org/specs/rfc9110.html#status.500", + 503 => "https://httpwg.org/specs/rfc9110.html#status.503", + _ => $"https://httpwg.org/specs/rfc9110.html#status.{(int)statusCode}" + }; + } + } +}