diff --git a/AGENTS.md b/AGENTS.md index 29d9469..344ea76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ script restore. Do not hand-edit lock files. `[]` modules, `AsyncSeq` for streaming. Avoid exceptions for control flow — convert to `GrpcError` / `ContractError` at boundaries. - Keep the contract boundary explicit: `ofContract` / `asContract` pairs convert between - `Feather.Contracts.*` proto types and domain types. + `Feather.Contracts.Core.V1.*` proto types and domain types. - Lint config lives in `fsharplint.json`. ## Release diff --git a/CHANGELOG.md b/CHANGELOG.md index 453c254..6d6ca7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Use current version of core contracts ## 1.0.0 - 2026-07-31 - Initial implementation diff --git a/README.md b/README.md index a2b01e8..c18fa0d 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ paket add Feather.Grpc ``` The conversions in this library map to the shared core proto types from -[grpc.contract.core](https://github.com/FeatherTools/grpc.contract.core) -(`Timestamp`, `CorrelationId`, `Spot`, `Instance`, `Box`, `Error`, +[Feather.Contracts](https://buf.build/feathertools/contracts) (`feather.contracts.core.v1` +package — `Timestamp`, `CorrelationId`, `Spot`, `Instance`, `Box`, `Error`, `SerializedForChunking`, …). Reference these in your own `.proto` definitions so messages interoperate across services and languages. @@ -54,7 +54,14 @@ Convert contract types at the boundary with `ofContract` / `asContract`. The example below is a `Calculator` service exposing a single `Divide` call. Proto definition — reuse the shared core types (`Spot`, `Error`) from -[grpc.contract.core](https://github.com/FeatherTools/grpc.contract.core): +[Feather.Contracts](https://buf.build/feathertools/contracts). Add the BSR module as a +dependency in your `buf.yaml`: + +```yaml +version: v2 +deps: + - buf.build/feathertools/contracts +``` ```proto syntax = "proto3"; @@ -62,7 +69,7 @@ syntax = "proto3"; package calculator; option csharp_namespace = "Math"; -import "feather/core.proto"; +import "feather/contracts/core/v1/core.proto"; message Input { int32 value = 1; } message Output { int32 value = 1; } @@ -74,8 +81,8 @@ message DivideRequest { message DivideResponse { oneof result { - Success success = 1; - feather.core.Error error = 2; + Success success = 1; + feather.contracts.core.v1.Error error = 2; } message Success { diff --git a/paket.dependencies b/paket.dependencies index a1c5b81..56773cd 100644 --- a/paket.dependencies +++ b/paket.dependencies @@ -11,7 +11,7 @@ nuget Alma.WebApplication ~> 14.0 # gRPC nuget Grpc.Net.Client ~> 2.70 -nuget Feather.Contracts ~> 1.0 +nuget Feather.Contracts ~> 1.2 nuget Feather.Cryptography ~> 2.0 nuget Feather.ErrorHandling ~> 2.0 diff --git a/paket.lock b/paket.lock index 796585e..fff7c82 100644 --- a/paket.lock +++ b/paket.lock @@ -79,7 +79,7 @@ NUGET DotNet.Glob (3.1.3) DynamicExpresso.Core (2.19.3) Microsoft.CSharp (>= 4.7) - Feather.Contracts (1.0) + Feather.Contracts (1.2) Google.Protobuf (>= 3.33.5 < 4.0) Grpc.Net.Client (>= 2.76 < 3.0) Feather.Cryptography (2.3) @@ -470,7 +470,7 @@ NUGET NuGet.Packaging (>= 7.6) - restriction: || (>= net472) (>= net8.0) System.Text.Json (>= 8.0.5) - restriction: >= net472 NuGet.Versioning (7.6) - restriction: || (>= net472) (>= net8.0) - System.Buffers (4.6.1) - restriction: || (&& (>= monoandroid) (< netstandard1.3) (>= netstandard2.0)) (&& (< monoandroid) (>= netcoreapp2.0) (< netcoreapp2.1) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (>= monotouch) (>= netcoreapp2.0)) (&& (>= monotouch) (>= netstandard2.0)) (&& (< net45) (< netcoreapp2.0) (>= netstandard2.0)) (&& (>= net462) (>= netcoreapp2.0)) (&& (>= net462) (>= netstandard2.0)) (>= net472) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) (&& (>= netstandard2.0) (>= uap10.1)) (&& (>= netstandard2.0) (>= xamarintvos)) (&& (>= netstandard2.0) (>= xamarinwatchos)) (>= xamarinios) (>= xamarinmac) + System.Buffers (4.6.1) - restriction: || (&& (>= monoandroid) (< netstandard1.3) (>= netstandard2.0)) (&& (< monoandroid) (>= netcoreapp2.0) (< netcoreapp2.1) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (>= monotouch) (>= netcoreapp2.0)) (&& (>= monotouch) (>= netstandard2.0)) (&& (< net45) (< netcoreapp2.0) (>= netstandard2.0)) (&& (>= net462) (>= netcoreapp2.0)) (&& (>= net462) (>= netstandard2.0)) (>= net472) (&& (>= netcoreapp2.0) (>= uap10.1)) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) (&& (>= netstandard2.0) (>= uap10.1)) (&& (>= netstandard2.0) (>= xamarintvos)) (&& (>= netstandard2.0) (>= xamarinwatchos)) (>= xamarinios) (>= xamarinmac) System.Collections.Immutable (10.0.10) - restriction: >= netstandard2.0 System.Memory (>= 4.6.3) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) System.Runtime.CompilerServices.Unsafe (>= 6.1.2) - restriction: || (>= net462) (&& (< net8.0) (>= netstandard2.0)) @@ -490,10 +490,10 @@ NUGET System.Buffers (>= 4.6.1) - restriction: || (>= net462) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) System.Numerics.Vectors (>= 4.6.1) - restriction: || (>= net462) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) System.Runtime.CompilerServices.Unsafe (>= 6.1.2) - restriction: || (>= net462) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) - System.Numerics.Vectors (4.6.1) - restriction: || (&& (< monoandroid) (>= netcoreapp2.0) (< netcoreapp2.1) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (< net45) (< netcoreapp2.0) (>= netstandard2.0) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (>= net462) (>= netcoreapp2.0)) (&& (>= net462) (>= netstandard2.0)) (>= net472) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) (&& (>= netstandard2.0) (>= uap10.1)) + System.Numerics.Vectors (4.6.1) - restriction: || (&& (< monoandroid) (>= netcoreapp2.0) (< netcoreapp2.1) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (< net45) (< netcoreapp2.0) (>= netstandard2.0) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (>= net462) (>= netcoreapp2.0)) (&& (>= net462) (>= netstandard2.0)) (>= net472) (&& (>= netcoreapp2.0) (>= uap10.1)) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) (&& (>= netstandard2.0) (>= uap10.1)) System.Reactive (6.1) - restriction: >= netstandard2.0 System.Threading.Tasks.Extensions (>= 4.5.4) - restriction: || (>= net472) (&& (< net6.0) (>= netstandard2.0)) (>= uap10.1) - System.Runtime.CompilerServices.Unsafe (6.1.2) - restriction: || (&& (< monoandroid) (>= netcoreapp2.0) (< netcoreapp2.1) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (< net45) (< netcoreapp2.0) (>= netstandard2.0) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (>= net462) (>= netcoreapp2.0)) (&& (>= net462) (>= netstandard2.0)) (>= net472) (&& (< net8.0) (>= netstandard2.0)) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) (&& (>= netstandard2.0) (>= uap10.1)) + System.Runtime.CompilerServices.Unsafe (6.1.2) - restriction: || (&& (< monoandroid) (>= netcoreapp2.0) (< netcoreapp2.1) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (< net45) (< netcoreapp2.0) (>= netstandard2.0) (< netstandard2.1) (< xamarintvos) (< xamarinwatchos)) (&& (>= net462) (>= netcoreapp2.0)) (&& (>= net462) (>= netstandard2.0)) (>= net472) (&& (< net8.0) (>= netstandard2.0)) (&& (>= netcoreapp2.0) (>= uap10.1)) (&& (< netcoreapp2.1) (>= netstandard2.0) (< netstandard2.1)) (&& (>= netstandard2.0) (>= uap10.1)) System.Security.AccessControl (6.0.1) - restriction: || (&& (>= monoandroid) (< netstandard1.3) (>= netstandard2.0)) (&& (< monoandroid) (>= netcoreapp2.0)) (&& (>= monotouch) (>= netstandard2.0)) (&& (< net45) (>= net461) (>= netstandard2.0)) (&& (< net45) (< netcoreapp2.0) (>= netstandard2.0)) (>= netcoreapp2.1) (&& (>= netstandard2.0) (>= uap10.1)) (&& (>= netstandard2.0) (>= xamarintvos)) (&& (>= netstandard2.0) (>= xamarinwatchos)) (>= xamarinios) (>= xamarinmac) System.Security.Principal.Windows (>= 5.0) - restriction: || (>= net461) (&& (< net6.0) (>= netstandard2.0)) System.Security.Cryptography.Pkcs (10.0.10) - restriction: >= net8.0 diff --git a/src/CoreTypes.fs b/src/CoreTypes.fs index 5639421..4ece475 100644 --- a/src/CoreTypes.fs +++ b/src/CoreTypes.fs @@ -16,12 +16,12 @@ module Timestamp = let value (Timestamp timestamp) = timestamp - let ofContract (contract: Feather.Contracts.Timestamp): Result = + let ofContract (contract: Feather.Contracts.Core.V1.Timestamp): Result = try contract.UnixTimestamp |> DateTimeOffset.FromUnixTimeMilliseconds |> Timestamp |> Ok with e -> Error (ContractError.ofExn e) - let asContract (Timestamp timestamp): Feather.Contracts.Timestamp = - Feather.Contracts.Timestamp (UnixTimestamp = timestamp.ToUnixTimeMilliseconds()) + let asContract (Timestamp timestamp): Feather.Contracts.Core.V1.Timestamp = + Feather.Contracts.Core.V1.Timestamp (UnixTimestamp = timestamp.ToUnixTimeMilliseconds()) type CorrelationId = CorrelationId of Guid @@ -43,10 +43,10 @@ module CorrelationId = let value = uuid >> string - let asContract (CorrelationId id): Feather.Contracts.CorrelationId = - Feather.Contracts.CorrelationId (Id = id.ToString()) + let asContract (CorrelationId id): Feather.Contracts.Core.V1.CorrelationId = + Feather.Contracts.Core.V1.CorrelationId (Id = id.ToString()) - let ofContract (contract: Feather.Contracts.CorrelationId): Result = + let ofContract (contract: Feather.Contracts.Core.V1.CorrelationId): Result = contract.Id |> Guid.tryParse |> Option.map CorrelationId @@ -56,26 +56,26 @@ open Alma.ServiceIdentification [] module Spot = - let ofContract (contract: Feather.Contracts.Spot): Result = + let ofContract (contract: Feather.Contracts.Core.V1.Spot): Result = Create.Spot(string contract.Zone, string contract.Bucket) |> Result.mapError (ContractError.ofFormattedError (sprintf "Invalid Spot(%A, %A): %A" contract.Zone contract.Bucket)) - let asContract (spot: Spot): Feather.Contracts.Spot = - Feather.Contracts.Spot (Zone = (spot.Zone |> Zone.value), Bucket = (spot.Bucket |> Bucket.value)) + let asContract (spot: Spot): Feather.Contracts.Core.V1.Spot = + Feather.Contracts.Core.V1.Spot (Zone = (spot.Zone |> Zone.value), Bucket = (spot.Bucket |> Bucket.value)) [] module Instance = - let ofContract (contract: Feather.Contracts.Instance): Result = + let ofContract (contract: Feather.Contracts.Core.V1.Instance): Result = contract.Instance_ |> Create.Instance |> Result.mapError (ContractError.ofFormattedError (sprintf "Invalid Instance: %A")) - let asContract instance: Feather.Contracts.Instance = - Feather.Contracts.Instance (Instance_ = (instance |> Instance.concat "-")) + let asContract instance: Feather.Contracts.Core.V1.Instance = + Feather.Contracts.Core.V1.Instance (Instance_ = (instance |> Instance.concat "-")) [] module Box = - let ofContract (contract: Feather.Contracts.Box): Result = + let ofContract (contract: Feather.Contracts.Core.V1.Box): Result = result { let! instance = contract.Instance |> Instance.ofContract |> Result.mapError (ContractError.map (sprintf "Box.Instance: %s")) let! spot = contract.Spot |> Spot.ofContract |> Result.mapError (ContractError.map (sprintf "Box.Spot: %s")) @@ -83,8 +83,8 @@ module Box = return Create.Box(instance, spot) } - let asContract (box: Box): Feather.Contracts.Box = - Feather.Contracts.Box( + let asContract (box: Box): Feather.Contracts.Core.V1.Box = + Feather.Contracts.Core.V1.Box( Instance = (box |> Box.instance |> Instance.asContract), Spot = (box |> Box.spot |> Spot.asContract) ) diff --git a/src/Error.fs b/src/Error.fs index 07ed979..fe2edbd 100644 --- a/src/Error.fs +++ b/src/Error.fs @@ -48,13 +48,13 @@ module GrpcError = Message = message } - let asContract (err: GrpcError): Feather.Contracts.Error = - Feather.Contracts.Error( + let asContract (err: GrpcError): Feather.Contracts.Core.V1.Error = + Feather.Contracts.Core.V1.Error( Name = err.Name, Message = (err.Message |> Option.defaultValue "") ) - let ofContract (contract: Feather.Contracts.Error): GrpcError = + let ofContract (contract: Feather.Contracts.Core.V1.Error): GrpcError = { Name = try contract.Name with _ -> "Contracts.Error.Null" Message = @@ -85,7 +85,7 @@ module GrpcError = match error with | :? exn as exn -> ofExn exn | :? ContractError as contractError -> ofContractError contractError - | :? Feather.Contracts.Error as contract -> ofContract contract + | :? Feather.Contracts.Core.V1.Error as contract -> ofContract contract | error -> { Name = error.GetType() |> string diff --git a/src/Serialization.fs b/src/Serialization.fs index 6f930d0..0949de4 100644 --- a/src/Serialization.fs +++ b/src/Serialization.fs @@ -26,7 +26,7 @@ module SerializedForChunking = /// Extracts and validates raw bytes from a single proto chunk. /// Returns an error if the chunk is null or empty. - let ofContract (contract: Feather.Contracts.SerializedForChunking): Result = + let ofContract (contract: Feather.Contracts.Core.V1.SerializedForChunking): Result = if isNull contract then Error (ContractError.ofError "SerializedForChunking: contract is null") else @@ -34,16 +34,16 @@ module SerializedForChunking = /// Extracts raw bytes from a proto chunk without validation. /// Use only where the chunk is known to be non-empty (e.g. just produced by asContract). - let bytesOf (contract: Feather.Contracts.SerializedForChunking): byte[] = + let bytesOf (contract: Feather.Contracts.Core.V1.SerializedForChunking): byte[] = contract.Content.ToByteArray() /// Wraps raw bytes into a single proto chunk. - let ofBytes (bytes: byte[]) : Feather.Contracts.SerializedForChunking = - Feather.Contracts.SerializedForChunking( + let ofBytes (bytes: byte[]) : Feather.Contracts.Core.V1.SerializedForChunking = + Feather.Contracts.Core.V1.SerializedForChunking( Content = Google.Protobuf.ByteString.CopyFrom bytes ) - let asContract (SerializedForChunking bytes) : Feather.Contracts.SerializedForChunking = + let asContract (SerializedForChunking bytes) : Feather.Contracts.Core.V1.SerializedForChunking = ofBytes bytes [] @@ -75,7 +75,7 @@ module SerializedForChunking = |> Array.toList |> List.map ofBytes - let concat (chunks: Feather.Contracts.SerializedForChunking list): Result = + let concat (chunks: Feather.Contracts.Core.V1.SerializedForChunking list): Result = chunks |> List.map ofContract |> Result.sequence @@ -90,7 +90,7 @@ module SerializedForChunking = /// dto /// |> SerializedForChunking.toChunks (MyType.toDto >> Serialize.toJson) /// |> Grpc.writeAll ctx writer - let toChunks (serialize: SerializeDto<'Dto>) (dto: SerializedForChunking<'Dto>): Feather.Contracts.SerializedForChunking list = + let toChunks (serialize: SerializeDto<'Dto>) (dto: SerializedForChunking<'Dto>): Feather.Contracts.Core.V1.SerializedForChunking list = dto |> Serialize.toBytes serialize |> Chunk.bytes @@ -101,22 +101,22 @@ module SerializedForChunking = /// receivedChunks /// |> List.map SerializedForChunking.ofContract /// |> SerializedForChunking.fromChunks MyType.parse - let fromChunks (parse: ParseDto<'Dto>) (chunks: Feather.Contracts.SerializedForChunking list): Result, ContractError> = + let fromChunks (parse: ParseDto<'Dto>) (chunks: Feather.Contracts.Core.V1.SerializedForChunking list): Result, ContractError> = chunks |> Chunk.concat |> Result.bind (Parse.fromBytes parse) [] module Dto = - type private ToStream<'Dto> = SerializeDto<'Dto> -> SerializedForChunking<'Dto> -> AsyncSeq - type private FromStream<'Dto> = ParseDto<'Dto> -> AsyncSeq -> AsyncResult, ContractError> + type private ToStream<'Dto> = SerializeDto<'Dto> -> SerializedForChunking<'Dto> -> AsyncSeq + type private FromStream<'Dto> = ParseDto<'Dto> -> AsyncSeq -> AsyncResult, ContractError> /// Serializes a dto and produces an AsyncSeq of proto chunks ready to be streamed via gRPC. /// Usage: /// dto /// |> SerializedForChunking.toStream (MyType.toDto >> Serialize.toJson) /// |> Grpc.writeAll ctx writer - let toStream (serialize: SerializeDto<'Dto>) dto: AsyncSeq = + let toStream (serialize: SerializeDto<'Dto>) dto: AsyncSeq = dto |> Serialize.toBytes serialize |> Chunk.bytes @@ -172,8 +172,8 @@ module SerializedForChunking = [] module Parts = - type private ToStream<'Parts> = SerializedForChunking<'Parts> -> AsyncSeq - type private FromStream<'Parts> = AsyncSeq -> AsyncSeq> + type private ToStream<'Parts> = SerializedForChunking<'Parts> -> AsyncSeq + type private FromStream<'Parts> = AsyncSeq -> AsyncSeq> /// Splits raw bytes into proto chunks and streams them. /// Usage: diff --git a/tests/CoreTypes.fs b/tests/CoreTypes.fs index 87dbc50..1838870 100644 --- a/tests/CoreTypes.fs +++ b/tests/CoreTypes.fs @@ -66,7 +66,7 @@ let timestampTests = Expect.equal contract.UnixTimestamp 1_700_000_000_000L "stores milliseconds" testCase "ofContract - restores from milliseconds" <| fun _ -> - let contract = Feather.Contracts.Timestamp(UnixTimestamp = 1_700_000_000_000L) + let contract = Feather.Contracts.Core.V1.Timestamp(UnixTimestamp = 1_700_000_000_000L) let result = Timestamp.ofContract contract let expected = DateTimeOffset.FromUnixTimeMilliseconds 1_700_000_000_000L Expect.equal result (Ok (Timestamp expected)) "restores DateTimeOffset" @@ -138,11 +138,11 @@ let correlationIdTests = Expect.equal result (Ok id) "round-trip preserves CorrelationId" testCase "ofContract - invalid guid" <| fun _ -> - let contract = Feather.Contracts.CorrelationId(Id = "not-a-guid") + let contract = Feather.Contracts.Core.V1.CorrelationId(Id = "not-a-guid") Expect.isError (CorrelationId.ofContract contract) "should reject invalid guid" testCase "ofContract - empty string" <| fun _ -> - let contract = Feather.Contracts.CorrelationId(Id = "") + let contract = Feather.Contracts.Core.V1.CorrelationId(Id = "") Expect.isError (CorrelationId.ofContract contract) "should reject empty string" ] @@ -166,10 +166,10 @@ let spotTests = Expect.equal contract.Bucket "staging" "bucket matches" testCase "ofContract - invalid zone" <| fun _ -> - let contract = Feather.Contracts.Spot(Zone = "", Bucket = "prod") + let contract = Feather.Contracts.Core.V1.Spot(Zone = "", Bucket = "prod") Expect.isError (Spot.ofContract contract) "should reject empty zone" testCase "ofContract - invalid bucket" <| fun _ -> - let contract = Feather.Contracts.Spot(Zone = "eu", Bucket = "") + let contract = Feather.Contracts.Core.V1.Spot(Zone = "eu", Bucket = "") Expect.isError (Spot.ofContract contract) "should reject empty bucket" ] diff --git a/tests/Serialization.fs b/tests/Serialization.fs index a2e75e9..dcc4ad8 100644 --- a/tests/Serialization.fs +++ b/tests/Serialization.fs @@ -192,7 +192,7 @@ let serializedForChunkingLargeFileTests = testCaseAsync "AI simulated response" <| async { do! Async.Sleep (6000 * sleepMultiplier) // ensure timestamp differences are visible in logs - let grpcStream = System.Threading.Channels.Channel.CreateUnbounded() + let grpcStream = System.Threading.Channels.Channel.CreateUnbounded() // AI engine: produces 10 partial messages, each encoded to one proto chunk, // written into the channel at its own pace. @@ -213,7 +213,7 @@ let serializedForChunkingLargeFileTests = } // gRPC stream as AsyncSeq — yields chunks as they are written by the producer. - let wireFromChannel : AsyncSeq = + let wireFromChannel : AsyncSeq = asyncSeq { let mutable running = true while running do