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
62 changes: 62 additions & 0 deletions OpenProject.Api.Test/Tests/WorkPackageScheduleJsonTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using OpenProject.Api.Data.Models;
using OpenProject.Api.Data.Models.Update;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace OpenProject.Api.Test.Tests;

/// <summary>
/// Work-package schedule fields are grouped in <see cref="WorkPackageSchedule"/> but stay flat in OpenProject's JSON.
/// </summary>
public class WorkPackageScheduleJsonTests
{
private static readonly JsonSerializerOptions Options = OpenProjectClient.CreateJsonOptions();

[Fact]
public void WorkPackage_ReadsFlatScheduleFieldsIntoSchedule()
{
var workPackage = JsonSerializer.Deserialize<WorkPackage>(
"""
{"_type":"WorkPackage","_links":{},"id":7,"subject":"Ship it","percentageDone":10,"derivedPercentageDone":20,
"scheduleManually":true,"startDate":"2026-11-02T00:00:00","dueDate":"2026-11-06T00:00:00",
"date":null,"estimatedTime":"PT8H","derivedEstimatedTime":"PT16H"}
""",
Options)!;

workPackage.Id.Should().Be(7);
workPackage.Subject.Should().Be("Ship it");
workPackage.Schedule.ScheduleManually.Should().BeTrue();
workPackage.Schedule.StartDate.Should().Be(new DateTime(2026, 11, 2));
workPackage.Schedule.DueDate.Should().Be(new DateTime(2026, 11, 6));
workPackage.Schedule.Date.Should().BeNull();
workPackage.Schedule.EstimatedTime.Should().Be("PT8H");
workPackage.Schedule.DerivedEstimatedTime.Should().Be("PT16H");
}

[Fact]
public void WorkPackageUpdate_WritesScheduleFieldsFlatAndLeavesNullsOut()
{
var update = new WorkPackageUpdate
{
LockVersion = 3,
Schedule = new WorkPackageSchedule { StartDate = new DateTime(2026, 11, 2), EstimatedTime = "PT4H" }
};

var json = JsonNode.Parse(JsonSerializer.Serialize(update, Options))!.AsObject();

json.Select(property => property.Key).Should().BeEquivalentTo("lockVersion", "startDate", "estimatedTime");
json["lockVersion"]!.GetValue<int>().Should().Be(3);
json["startDate"]!.GetValue<string>().Should().Be("2026-11-02T00:00:00");
json["estimatedTime"]!.GetValue<string>().Should().Be("PT4H");
}

[Fact]
public void WorkPackagesInACollection_AreReadThroughTheSameConverter()
{
var workPackages = JsonSerializer.Deserialize<List<WorkPackage>>(
"""[{"_type":"WorkPackage","_links":{},"id":1,"subject":"a","percentageDone":0,"derivedPercentageDone":0,"dueDate":"2026-12-24T00:00:00"}]""",
Options)!;

workPackages.Should().ContainSingle().Which.Schedule.DueDate.Should().Be(new DateTime(2026, 12, 24));
}
}
63 changes: 63 additions & 0 deletions OpenProject.Api/Data/FlatScheduleConverterFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using OpenProject.Api.Data.Models;
using System.Text.Json;
using System.Text.Json.Serialization;
using Type = System.Type;

namespace OpenProject.Api.Data;

/// <summary>
/// Reads and writes <see cref="IHasSchedule"/> models with their <see cref="WorkPackageSchedule"/> fields at the
/// top level of the JSON object, where OpenProject puts them.
/// </summary>
internal sealed class FlatScheduleConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
=> typeToConvert.IsClass && typeof(IHasSchedule).IsAssignableFrom(typeToConvert);

public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
=> (JsonConverter)Activator.CreateInstance(
typeof(FlatScheduleConverter<>).MakeGenericType(typeToConvert),
WithoutThisFactory(options))!;

// Without this, serializing the model inside the converter would re-enter the converter.
private static JsonSerializerOptions WithoutThisFactory(JsonSerializerOptions options)
{
var inner = new JsonSerializerOptions(options);
for (var i = inner.Converters.Count - 1; i >= 0; i--)
{
if (inner.Converters[i] is FlatScheduleConverterFactory)
{
inner.Converters.RemoveAt(i);
}
}

return inner;
}

private sealed class FlatScheduleConverter<T>(JsonSerializerOptions inner) : JsonConverter<T>
where T : class, IHasSchedule
{
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var document = JsonDocument.ParseValue(ref reader);
var item = document.RootElement.Deserialize<T>(inner);
if (item is not null)
{
item.Schedule = document.RootElement.Deserialize<WorkPackageSchedule>(inner) ?? new();
}

return item;
}

public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
var node = JsonSerializer.SerializeToNode(value, inner)!.AsObject();
foreach (var (name, field) in JsonSerializer.SerializeToNode(value.Schedule, inner)!.AsObject())
{
node[name] = field?.DeepClone();
}

node.WriteTo(writer, inner);
}
}
}
12 changes: 12 additions & 0 deletions OpenProject.Api/Data/Models/IHasSchedule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace OpenProject.Api.Data.Models;

/// <summary>
/// A model whose schedule fields are flat in OpenProject's JSON but grouped in <see cref="Schedule"/>.
/// </summary>
public interface IHasSchedule
{
/// <summary>
/// The scheduling and estimate fields.
/// </summary>
WorkPackageSchedule Schedule { get; set; }
}
41 changes: 6 additions & 35 deletions OpenProject.Api/Data/Models/Update/WorkPackageUpdate.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
using System.Text.Json.Serialization;
using OpenProject.Api.Data.Models;
using System.Text.Json.Serialization;

namespace OpenProject.Api.Data.Models.Update;

/// <summary>
/// Represents the fields that can be updated on an existing work package.
/// </summary>
public class WorkPackageUpdate
public class WorkPackageUpdate : IHasSchedule
{
/// <summary>
/// The value of lockVersion is used to implement optimistic locking.
Expand All @@ -26,38 +27,8 @@ public class WorkPackageUpdate
public Formattable? Description { get; set; }

/// <summary>
/// If false (default) schedule automatically.
/// Scheduling and estimate fields to change; fields left null are not sent.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ScheduleManually { get; set; }

/// <summary>
/// Scheduled beginning of a work package
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTime? StartDate { get; set; }

/// <summary>
/// Scheduled end of a work package
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTime? DueDate { get; set; }

/// <summary>
/// Date on which a milestone is achieved
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTime? Date { get; set; }

/// <summary>
/// Corresponds to work. Time a work package likely needs to be completed.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? EstimatedTime { get; set; }

/// <summary>
/// Corresponds to total work. Time a work package likely needs to be completed including itself and its descendants.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? DerivedEstimatedTime { get; set; }
[JsonIgnore]
public WorkPackageSchedule Schedule { get; set; } = new();
}
32 changes: 4 additions & 28 deletions OpenProject.Api/Data/Models/WorkPackage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace OpenProject.Api.Data.Models;
/// A work package in OpenProject can basically be everything you need to keep track off within your projects. It can be e.g. a task, a feature, a bug, a risk, a milestone or a project phase.
/// <para>See <a href='https://www.openproject.org/docs/getting-started/work-packages-introduction/'/></para>
/// </summary>
public class WorkPackage : IdentifiedItem<int>, IHasTimestamps
public class WorkPackage : IdentifiedItem<int>, IHasTimestamps, IHasSchedule
{
/// <summary>
/// The version of the item as used for optimistic locking
Expand All @@ -27,24 +27,10 @@ public class WorkPackage : IdentifiedItem<int>, IHasTimestamps
public Formattable? Description { get; set; }

/// <summary>
/// If false (default) schedule automatically.
/// Scheduling and estimate fields: start, due and milestone dates, manual scheduling and estimated time.
/// </summary>
public bool? ScheduleManually { get; set; }

/// <summary>
/// Scheduled beginning of a work package
/// </summary>
public DateTime? StartDate { get; set; }

/// <summary>
/// Scheduled end of a work package
/// </summary>
public DateTime? DueDate { get; set; }

/// <summary>
/// Date on which a milestone is achieved
/// </summary>
public DateTime? Date { get; set; }
[JsonIgnore]
public WorkPackageSchedule Schedule { get; set; } = new();

/// <summary>
/// Similar to start date but is not set by a client but rather deduced by the work packages’ descendants. If manual scheduleManually is active, the two dates can deviate.
Expand All @@ -61,16 +47,6 @@ public class WorkPackage : IdentifiedItem<int>, IHasTimestamps
/// </summary>
public string? Duration { get; set; }

/// <summary>
/// Corresponds to work. Time a work package likely needs to be completed.
/// </summary>
public string? EstimatedTime { get; set; }

/// <summary>
/// Corresponds to total work. Time a work package likely needs to be completed including itself and its descendants.
/// </summary>
public string? DerivedEstimatedTime { get; set; }

/// <summary>
/// Corresponds to remaining work. Remaining time a work package likely needs to be completed.
/// </summary>
Expand Down
46 changes: 46 additions & 0 deletions OpenProject.Api/Data/Models/WorkPackageSchedule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Text.Json.Serialization;

namespace OpenProject.Api.Data.Models;

/// <summary>
/// The scheduling and estimate fields of a work package. They are top-level fields in OpenProject's JSON;
/// <see cref="IHasSchedule"/> models group them here.
/// </summary>
public class WorkPackageSchedule
{
/// <summary>
/// If false (default) schedule automatically.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? ScheduleManually { get; set; }

/// <summary>
/// Scheduled beginning of a work package
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTime? StartDate { get; set; }

/// <summary>
/// Scheduled end of a work package
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTime? DueDate { get; set; }

/// <summary>
/// Date on which a milestone is achieved
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTime? Date { get; set; }

/// <summary>
/// Corresponds to work. Time a work package likely needs to be completed.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? EstimatedTime { get; set; }

/// <summary>
/// Corresponds to total work. Time a work package likely needs to be completed including itself and its descendants.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? DerivedEstimatedTime { get; set; }
}
3 changes: 3 additions & 0 deletions OpenProject.Api/OpenProject.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@
<None Include="Icon.png" Pack="true" PackagePath="" />
<None Include="..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenProject.Api.Test" />
</ItemGroup>
</Project>
11 changes: 11 additions & 0 deletions OpenProject.Api/OpenProjectClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public OpenProjectClient(HttpClient client)
_httpClient = client;
_refitSettings = new RefitSettings
{
ContentSerializer = new SystemTextJsonContentSerializer(CreateJsonOptions()),
//UrlParameterFormatter = new OpenProjectUrlParameterFormatter(),
};

Expand Down Expand Up @@ -59,6 +60,16 @@ public OpenProjectClient(HttpClient client)
Types = RefitFor<ITypes>();
}

/// <summary>
/// Refit's default JSON options, plus the converter that keeps work-package schedule fields flat on the wire.
/// </summary>
internal static System.Text.Json.JsonSerializerOptions CreateJsonOptions()
{
var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions();
options.Converters.Add(new FlatScheduleConverterFactory());
return options;
}

private T RefitFor<T>()
=> RestService.For<T>(_httpClient, _refitSettings);

Expand Down
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
"version": "3.0",
"version": "3.1",
"publicReleaseRefSpec": [
"^refs/heads/main$",
"^refs/heads/release/\\d+\\.\\d+$",
Expand Down
Loading