Skip to content

parseysharp/ParseySharp

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

45 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ParseySharp

Write your parser once. Run it everywhere (JSON, XML, YAML, Protobuf, MessagePack, Avro). First-class ASP.NET Core integration.

This repository hosts the ParseySharp core and its ecosystem packages. Parsers return path-aware, accumulated errors, so you get precise paths (e.g., ["[3]","paymentMethod","cvv"]) and all issues across arrays/records in a single response.

  • Core: ParseySharp/
  • ASP.NET Core: ParseySharp.AspNetCore/
  • Carrier adapters: ParseySharp.* (YamlDotNet, Protobuf, MessagePack, Avro, etc.)
  • Swagger helpers: ParseySharp.Swashbuckle/
  • Samples: Samples/

Install

Add packages as needed (pick from the table below):

dotnet add package ParseySharp
# Optional adapters / integrations
# dotnet add package ParseySharp.AspNetCore
# dotnet add package ParseySharp.YamlDotNet
# dotnet add package ParseySharp.Protobuf
# dotnet add package ParseySharp.MessagePack
# dotnet add package ParseySharp.Avro
# dotnet add package ParseySharp.Swashbuckle

30‑second Quick Start

Build one parser and reuse it across carriers.

using ParseySharp;
using ParseySharp.Refine;

public abstract record PaymentMethod;
public sealed record Card(string Number, int Cvv) : PaymentMethod;
public sealed record Ach(string RoutingNumber, string AccountNumber) : PaymentMethod;

public enum PaymentMethodType { Card, Ach }

public readonly record struct ValidPayment(Refine.Refined<PaymentMethod, ValidPayment> Inner)
  : Refine.IRefine<PaymentMethod, ValidPayment>
{
  public static LanguageExt.Seq<string> Errors(PaymentMethod x) =>
    x switch
    {
      Card c =>
        "CVV is invalid".ErrUnless(() => c.Cvv is >= 100 and <= 999 || c.Cvv is >= 1000 and <= 9999),
      Ach a =>
        "ACH account number is invalid".ErrUnless(() => !string.IsNullOrWhiteSpace(a.AccountNumber)),
      _ => LanguageExt.Seq<string>.Empty
    };

  public static LanguageExt.Either<LanguageExt.Seq<string>, ValidPayment> Refined(PaymentMethod x)
    => Refine.Create<PaymentMethod, ValidPayment>(x).Map(v => new ValidPayment(v));
}

var paymentParser =
  (from paymentMethodType in Parse.Enum<PaymentMethodType>().At("type", [])
   from pm in paymentMethodType switch
   {
     PaymentMethodType.Card => (
         Parse.As<string>().At("number"),
         Parse.Int32Flex().At("cvv")
       ).Apply((number, cvv) => (PaymentMethod)new Card(number, cvv)).As(),
     PaymentMethodType.Ach => (
         Parse.As<string>().At("routingNumber"),
         Parse.As<string>().At("accountNumber")
       ).Apply((routingNumber, accountNumber) => (PaymentMethod)new Ach(routingNumber, accountNumber)).As(),
     _ => Parse.Fail<PaymentMethod>("Unsupported type")
   }
   select pm)
  .As()
  .Filter(ValidPayment.Refined);

// JSON (works the same for XML/YAML/Protobuf/MessagePack/Avro)
var cardJson = """{ "type":"Card", "number":"4111111111111111", "cvv": 123 }""";
var okCard = paymentParser.ParseJson()(System.Text.Json.JsonDocument.Parse(cardJson).RootElement);

var achJson = """{ "type":"Ach", "routingNumber":"021000021", "accountNumber":"000123456789" }""";
var okAch = paymentParser.ParseJson()(System.Text.Json.JsonDocument.Parse(achJson).RootElement);

// Array parse with accumulated, path-aware errors
var invalidBatchJson = """
[
  { "type":"Card", "number":"4111111111111111", "cvv": 123 },
  { "type":"Card", "number":"4111111111111111", "cvv": "abc" }, // type mismatch
  { "type":"Card", "cvv": 123 } // missing field; error at parent
]
""";
var invalidBatchResult = paymentParser
  .Seq()
  .ParseJson()(System.Text.Json.JsonDocument.Parse(invalidBatchJson).RootElement);

// Produces the following errors:
// [
//   {
//     "message": "Invalid integer: abc",
//     "expected": "Int32",
//     "actual": "abc",
//     "path": ["[1]", "cvv"]
//   },
//   {
//     "message": "Missing property number",
//     "expected": "String",
//     "actual": { "type": "Card", "cvv": 123 },
//     "path": ["[2]"]
//   }
// ]

Concepts in 60 seconds

  • Parse.As<T>() — atomic parser for T
  • .At("field", []) — navigate to a field/path
  • .Option() — optional field (Option)
  • .Apply((...) => new T(...)) — map tuple results into your type
  • .As() — finalize the parser
  • Return type: Validation<Seq<ParsePathErr>, T> with path-aware errors

Practical example

A common pattern is a discriminated payload: a type field decides which fields are required. The parser branches on type and then applies a refinement step to ensure business rules are met.

// See Samples/ParseySharp.SampleWeb/Checkout.cs for a complete version
var paymentParser =
  (from paymentMethodType in Parse.Enum<PaymentMethodType>().At("type", [])
   from pm in paymentMethodType switch
   {
     PaymentMethodType.Card => (
         Parse.As<string>().At("number", []),
         Parse.Int32Flex().At("cvv", [])
       ).Apply((number, cvv) => (PaymentMethod)new Card(number, cvv)).As(),
     PaymentMethodType.Ach => (
         Parse.As<string>().At("routingNumber", []),
         Parse.As<string>().At("accountNumber", [])
       ).Apply((routingNumber, accountNumber) => (PaymentMethod)new Ach(routingNumber, accountNumber)).As()
   }
   select pm)
  .As()
  .Filter(x => Refine.Create<PaymentMethod, ValidPayment>(x).Map(v => new ValidPayment(v)));

This uses C# LINQ query syntax over Parse<T> to express dependent parsing (the second from depends on the first’s value) and Refine to enforce domain invariants. The same parser runs over JSON, XML, YAML, Protobuf, MessagePack, Avro, etc.

Recipe index

  • Optional fields: .Option()
  • Alternatives: p1.OrElse(p2)
  • Validation: .Filter(pred => Left("why"))
  • Arrays and streaming: parser.Seq(), streaming variants
  • Combine fields into a record: tuple + .Apply(...)
  • Normalize types: string→int via .Filter or OrElse

See ParseySharp.Tests/ and Samples/ for working recipes.

  • Default primitives for flexible, carrier-friendly parsing: ParseySharp/DefaultParsers.cs
  • End-to-end example used throughout this README: Samples/ParseySharp.SampleWeb/Checkout.cs

ASP.NET Core in two lines

Minimal API:

app.MapParsedPost("/payment", paymentParser, (ValidPayment x) => Results.Ok(x))
   .AcceptsJson().AcceptsXml().AcceptsFormUrlEncoded();

MVC:

[HttpPost("/mvc/payment")]
[AcceptsJson][AcceptsXml][AcceptsFormUrlEncoded]
public Task<IActionResult> Post(CancellationToken ct)
  => this.ParsedAsync(paymentParser, x => Ok(x), ct);

Swagger helpers: add ParseySharp.Swashbuckle and RequestModel annotations (Minimal API: SetRequestModel<T>(), MVC: [RequestModel<T>]).

Carrier adapters

  • JSON (System.Text.Json): Core
  • Newtonsoft.Json: ParseySharp.NewtonsoftJson
  • XML: Core
  • YAML: ParseySharp.YamlDotNet
  • Protobuf: ParseySharp.Protobuf
  • MessagePack: ParseySharp.MessagePack
  • Avro: ParseySharp.Avro
  • DataTables: Core
  • In‑memory objects (Map/List/etc): Core
  • Reflection: Core

Samples and tests

  • Samples: Samples/ParseySharp.SampleWeb/ (Minimal API + MVC, multi‑format endpoints)
  • Tests: ParseySharp.Tests/ (broad carrier coverage)

License

Apache-2.0. See LICENSE.

Extending to new formats (4 functions)

To add a new carrier, you only need to supply four functions to a ParsePathNav<TCarrier>:

  • Prop (drill into a carrier by name) — returns NavStep<TCarrier>
  • Index (drill into a carrier by array index) — returns NavStep<TCarrier>
  • Unbox (extract a primitive value from a carrier) — returns UnboxStep
  • CloneNode (clone or otherwise produce an owned value for safe iteration/yielding)

NavStep<S> has four arms — NotApplicable (the carrier is not the right shape for this lookup), Value (lookup found a usable child), Null (lookup found the format's null sentinel), Absent (lookup found no entry at this key/index). UnboxStep has three — NotApplicable / Value / Null. Each variant is constructed via a named static factory; the compiler enforces exhaustive matching on the consumer side.

Here’s a concrete illustration using a familiar type, System.Text.Json.JsonElement.

using System.Text.Json;
using ParseySharp;

public static class ParsePathNavJson
{
  public static readonly ParsePathNav<JsonElement> Json = ParsePathNav<JsonElement>.Create(
    Prop: (node, name) =>
      node.ValueKind == JsonValueKind.Object
        ? (node.TryGetProperty(name, out var child)
            ? (child.ValueKind == JsonValueKind.Null || child.ValueKind == JsonValueKind.Undefined
                ? NavStep.Null<JsonElement>()
                : NavStep.Value<JsonElement>(child))
            : NavStep.Absent<JsonElement>())
        : NavStep.NotApplicable(node),

    Index: (node, i) =>
      node.ValueKind == JsonValueKind.Array && i >= 0
        ? (i < node.GetArrayLength()
            ? (node[i].ValueKind == JsonValueKind.Null || node[i].ValueKind == JsonValueKind.Undefined
                ? NavStep.Null<JsonElement>()
                : NavStep.Value<JsonElement>(node[i]))
            : NavStep.Absent<JsonElement>())
        : NavStep.NotApplicable(node),

    Unbox: node => node.ValueKind switch
    {
      JsonValueKind.Null   => UnboxStep.Null(),
      JsonValueKind.String => UnboxStep.Value(node.GetString()!),
      JsonValueKind.True   => UnboxStep.Value(true),
      JsonValueKind.False  => UnboxStep.Value(false),
      // handle other cases
      _ => UnboxStep.NotApplicable(node)
    },

    CloneNode: node =>
      node.ValueKind == JsonValueKind.Undefined
        ? JsonDocument.Parse("null").RootElement.Clone()
        : node.Clone()
  );
}

// Using your custom navigator with any parser:
//   var result = parser.RunWithNav(ParsePathNavJson.Json)(jsonElement);

See a production-grade example in ParseySharp.Avro/Navigate.Avro.cs, which defines a navigator over Avro generic values by implementing these four functions.

About

Parse all the things! (in c#)

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages