Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ script restore. Do not hand-edit lock files.
`[<RequireQualifiedAccess>]` 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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<!-- There is always Unreleased section on the top. Subsections (Add, Changed, Fix, Removed) should be Add as needed. -->
## Unreleased
- Use current version of core contracts

## 1.0.0 - 2026-07-31
- Initial implementation
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -54,15 +54,22 @@ 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";

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; }
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion paket.dependencies
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions paket.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down
30 changes: 15 additions & 15 deletions src/CoreTypes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ module Timestamp =

let value (Timestamp timestamp) = timestamp

let ofContract (contract: Feather.Contracts.Timestamp): Result<Timestamp, ContractError> =
let ofContract (contract: Feather.Contracts.Core.V1.Timestamp): Result<Timestamp, ContractError> =
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

Expand All @@ -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<CorrelationId, ContractError> =
let ofContract (contract: Feather.Contracts.Core.V1.CorrelationId): Result<CorrelationId, ContractError> =
contract.Id
|> Guid.tryParse
|> Option.map CorrelationId
Expand All @@ -56,35 +56,35 @@ open Alma.ServiceIdentification

[<RequireQualifiedAccess>]
module Spot =
let ofContract (contract: Feather.Contracts.Spot): Result<Spot, ContractError> =
let ofContract (contract: Feather.Contracts.Core.V1.Spot): Result<Spot, ContractError> =
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))

[<RequireQualifiedAccess>]
module Instance =
let ofContract (contract: Feather.Contracts.Instance): Result<Instance, ContractError> =
let ofContract (contract: Feather.Contracts.Core.V1.Instance): Result<Instance, ContractError> =
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 "-"))

[<RequireQualifiedAccess>]
module Box =
let ofContract (contract: Feather.Contracts.Box): Result<Box, ContractError> =
let ofContract (contract: Feather.Contracts.Core.V1.Box): Result<Box, ContractError> =
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"))

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)
)
8 changes: 4 additions & 4 deletions src/Error.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down
26 changes: 13 additions & 13 deletions src/Serialization.fs
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,24 @@ 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<byte[], ContractError> =
let ofContract (contract: Feather.Contracts.Core.V1.SerializedForChunking): Result<byte[], ContractError> =
if isNull contract then
Error (ContractError.ofError "SerializedForChunking: contract is null")
else
Ok <| contract.Content.ToByteArray()

/// 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

[<RequireQualifiedAccess>]
Expand Down Expand Up @@ -75,7 +75,7 @@ module SerializedForChunking =
|> Array.toList
|> List.map ofBytes

let concat (chunks: Feather.Contracts.SerializedForChunking list): Result<byte[], ContractError> =
let concat (chunks: Feather.Contracts.Core.V1.SerializedForChunking list): Result<byte[], ContractError> =
chunks
|> List.map ofContract
|> Result.sequence
Expand All @@ -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
Expand All @@ -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<SerializedForChunking<'Dto>, ContractError> =
let fromChunks (parse: ParseDto<'Dto>) (chunks: Feather.Contracts.Core.V1.SerializedForChunking list): Result<SerializedForChunking<'Dto>, ContractError> =
chunks
|> Chunk.concat
|> Result.bind (Parse.fromBytes parse)

[<RequireQualifiedAccess>]
module Dto =
type private ToStream<'Dto> = SerializeDto<'Dto> -> SerializedForChunking<'Dto> -> AsyncSeq<Feather.Contracts.SerializedForChunking>
type private FromStream<'Dto> = ParseDto<'Dto> -> AsyncSeq<Feather.Contracts.SerializedForChunking> -> AsyncResult<SerializedForChunking<'Dto>, ContractError>
type private ToStream<'Dto> = SerializeDto<'Dto> -> SerializedForChunking<'Dto> -> AsyncSeq<Feather.Contracts.Core.V1.SerializedForChunking>
type private FromStream<'Dto> = ParseDto<'Dto> -> AsyncSeq<Feather.Contracts.Core.V1.SerializedForChunking> -> AsyncResult<SerializedForChunking<'Dto>, 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<Feather.Contracts.SerializedForChunking> =
let toStream (serialize: SerializeDto<'Dto>) dto: AsyncSeq<Feather.Contracts.Core.V1.SerializedForChunking> =
dto
|> Serialize.toBytes serialize
|> Chunk.bytes
Expand Down Expand Up @@ -172,8 +172,8 @@ module SerializedForChunking =

[<RequireQualifiedAccess>]
module Parts =
type private ToStream<'Parts> = SerializedForChunking<'Parts> -> AsyncSeq<Feather.Contracts.SerializedForChunking>
type private FromStream<'Parts> = AsyncSeq<Feather.Contracts.SerializedForChunking> -> AsyncSeq<SerializedForChunking<'Parts>>
type private ToStream<'Parts> = SerializedForChunking<'Parts> -> AsyncSeq<Feather.Contracts.Core.V1.SerializedForChunking>
type private FromStream<'Parts> = AsyncSeq<Feather.Contracts.Core.V1.SerializedForChunking> -> AsyncSeq<SerializedForChunking<'Parts>>

/// Splits raw bytes into proto chunks and streams them.
/// Usage:
Expand Down
10 changes: 5 additions & 5 deletions tests/CoreTypes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
]

Expand All @@ -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"
]
Loading