Skip to content

Commit 075c1b9

Browse files
Add Typed ServiceVolume Models With Custom Collection (#191)
* Changes Volumes property from List<string> to ServiceVolumeCollection, which implements both IList<string> and IList<ServiceVolume>. This allows both patterns to work: - foreach (string v in service.Volumes) - backwards compatible - foreach (var v in service.Volumes) - typed access (ServiceVolume) - string x = service.Volumes[0] - implicit conversion - ServiceVolume y = service.Volumes[0] - typed access Note: This is a minor breaking change for code using 'var' with volumes, as the inferred type changes from string to ServiceVolume. * fix formatting issue update complex example * Keep class per file * Define PLACE_ACCESSORHOLDER_ATTRIBUTE_ON_SAME_LINE_EX * Simplify deserializer * Simplify serializer --------- Co-authored-by: aus-design-web-mobile <aus-design-web-mobile@users.noreply.github.com> Co-authored-by: Jan Trejbal <jan@trejbal.land>
1 parent 0e15053 commit 075c1b9

13 files changed

Lines changed: 474 additions & 6 deletions

File tree

src/DockerComposeBuilder.Examples.Complex/Program.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
.WithImage("mysql:5.7")
2727
.WithNetworks(network1)
2828
.WithExposed("3306")
29+
.WithVolumes("mysql-data:/var/lib/mysql")
2930
.WithEnvironment(mb =>
3031
{
3132
mb.Add("MYSQL_ROOT_PASSWORD", dbPass);
@@ -62,6 +63,21 @@
6263
{
6364
Target = 83,
6465
})
66+
.WithVolumes(
67+
new ServiceVolume
68+
{
69+
Type = "bind",
70+
Source = "./wp-content",
71+
Target = "/var/www/html/wp-content"
72+
},
73+
new ServiceVolume
74+
{
75+
Type = "volume",
76+
Source = "wp-uploads",
77+
Target = "/var/www/html/wp-content/uploads",
78+
ReadOnly = false
79+
}
80+
)
6581
.WithEnvironment(mb =>
6682
{
6783
mb.Add("WORDPRESS_DB_HOST", $"{mysql.Name}:3306");

src/DockerComposeBuilder.Tests/ComposeBuilderTests.cs

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using DockerComposeBuilder.Builders;
22
using DockerComposeBuilder.Extensions;
3+
using DockerComposeBuilder.Model.Services;
34
using DockerComposeBuilder.Model.Services.BuildArguments;
45
using System.Collections.Generic;
56
using Xunit;
@@ -320,7 +321,7 @@ public void DeserializeWithPortRangesTest()
320321
}
321322

322323
[Fact]
323-
public void DeserializeWithVolumesTest()
324+
public void DeserializeWithVolumesShortSyntaxTest()
324325
{
325326
var yaml = // language=yaml
326327
"""
@@ -341,7 +342,157 @@ public void DeserializeWithVolumesTest()
341342
var appService = compose.Services["app"];
342343
Assert.NotNull(appService.Volumes);
343344
Assert.Equal(2, appService.Volumes.Count);
345+
346+
// Works as string (backwards compatible via implicit conversion)
344347
Assert.Equal("./data:/app/data", appService.Volumes[0]);
345348
Assert.Equal("cache:/app/cache", appService.Volumes[1]);
349+
350+
// Also works as ServiceVolume (typed access)
351+
ServiceVolume firstVolume = appService.Volumes[0];
352+
Assert.Equal("./data:/app/data", firstVolume.ShortSyntax);
353+
}
354+
355+
[Fact]
356+
public void DeserializeWithVolumesLongSyntaxTest()
357+
{
358+
var yaml = // language=yaml
359+
"""
360+
version: "3.8"
361+
services:
362+
app:
363+
image: myapp:latest
364+
volumes:
365+
- type: bind
366+
source: ./data
367+
target: /app/data
368+
read_only: true
369+
- type: volume
370+
source: cache
371+
target: /app/cache
372+
volumes:
373+
cache:
374+
""";
375+
376+
var compose = ComposeExtensions.Deserialize(yaml);
377+
378+
Assert.NotNull(compose.Services);
379+
var appService = compose.Services["app"];
380+
Assert.NotNull(appService.Volumes);
381+
Assert.Equal(2, appService.Volumes.Count);
382+
383+
Assert.Equal("bind", appService.Volumes[0].Type);
384+
Assert.Equal("./data", appService.Volumes[0].Source);
385+
Assert.Equal("/app/data", appService.Volumes[0].Target);
386+
Assert.True(appService.Volumes[0].ReadOnly);
387+
388+
Assert.Equal("volume", appService.Volumes[1].Type);
389+
Assert.Equal("cache", appService.Volumes[1].Source);
390+
Assert.Equal("/app/cache", appService.Volumes[1].Target);
391+
Assert.Null(appService.Volumes[1].ReadOnly);
392+
}
393+
394+
[Fact]
395+
public void VolumesSupportsForEachWithStringTest()
396+
{
397+
var compose = Builder.MakeCompose()
398+
.WithServices(
399+
Builder.MakeService("app")
400+
.WithImage("myapp:latest")
401+
.WithVolumes("./data:/app/data", "cache:/app/cache")
402+
.Build()
403+
)
404+
.Build();
405+
406+
var appService = compose.Services!["app"];
407+
var volumeStrings = new List<string>();
408+
409+
// This is the backwards-compatible pattern that must work
410+
foreach (string v in appService.Volumes!)
411+
{
412+
volumeStrings.Add(v);
413+
}
414+
415+
Assert.Equal(2, volumeStrings.Count);
416+
Assert.Equal("./data:/app/data", volumeStrings[0]);
417+
Assert.Equal("cache:/app/cache", volumeStrings[1]);
418+
}
419+
420+
[Fact]
421+
public void SerializeWithVolumesShortSyntaxTest()
422+
{
423+
var compose = Builder.MakeCompose()
424+
.WithServices(
425+
Builder.MakeService("app")
426+
.WithImage("myapp:latest")
427+
.WithVolumes("./data:/app/data", "cache:/app/cache")
428+
.Build()
429+
)
430+
.Build();
431+
432+
var result = compose.Serialize();
433+
434+
Assert.Equal(
435+
// language=yaml
436+
"""
437+
version: "3.8"
438+
services:
439+
app:
440+
image: "myapp:latest"
441+
volumes:
442+
- "./data:/app/data"
443+
- "cache:/app/cache"
444+
445+
""",
446+
result
447+
);
448+
}
449+
450+
[Fact]
451+
public void SerializeWithVolumesLongSyntaxTest()
452+
{
453+
var compose = Builder.MakeCompose()
454+
.WithServices(
455+
Builder.MakeService("app")
456+
.WithImage("myapp:latest")
457+
.WithVolumes(
458+
new ServiceVolume
459+
{
460+
Type = "bind",
461+
Source = "./data",
462+
Target = "/app/data",
463+
ReadOnly = true
464+
},
465+
new ServiceVolume
466+
{
467+
Type = "volume",
468+
Source = "cache",
469+
Target = "/app/cache"
470+
}
471+
)
472+
.Build()
473+
)
474+
.Build();
475+
476+
var result = compose.Serialize();
477+
478+
Assert.Equal(
479+
// language=yaml
480+
"""
481+
version: "3.8"
482+
services:
483+
app:
484+
image: "myapp:latest"
485+
volumes:
486+
- type: "bind"
487+
source: "./data"
488+
target: "/app/data"
489+
read_only: true
490+
- type: "volume"
491+
source: "cache"
492+
target: "/app/cache"
493+
494+
""",
495+
result
496+
);
346497
}
347498
}

src/DockerComposeBuilder.sln.DotSettings

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
22
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/INDENT_RAW_LITERAL_STRING/@EntryValue">INDENT</s:String>
3+
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/PLACE_ACCESSORHOLDER_ATTRIBUTE_ON_SAME_LINE_EX/@EntryValue">NEVER</s:String>
34
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EUnitTestFramework_002EMigrations_002EEnableDisabledProvidersMigration/@EntryIndexedValue">True</s:Boolean>
45
<s:Boolean x:Key="/Default/Environment/UnitTesting/DisabledProviders/=Testing_0020Platform/@EntryIndexedValue">False</s:Boolean>
56
<s:Boolean x:Key="/Default/Environment/UnitTesting/DisabledProviders/=VsTest/@EntryIndexedValue">True</s:Boolean>

src/DockerComposeBuilder/Builders/ServiceBuilder.cs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,19 @@ public SwarmServiceBuilder WithSwarm()
219219

220220
public ServiceBuilder WithVolumes(params string[] volumes)
221221
{
222-
if (WorkingObject.Volumes == null)
223-
{
224-
WorkingObject.Volumes = new List<string>();
225-
}
222+
WorkingObject.Volumes ??= new ServiceVolumeCollection();
223+
224+
WorkingObject.Volumes.AddRange(volumes);
225+
226+
return this;
227+
}
228+
229+
public ServiceBuilder WithVolumes(params ServiceVolume[] volumes)
230+
{
231+
WorkingObject.Volumes ??= new ServiceVolumeCollection();
226232

227233
WorkingObject.Volumes.AddRange(volumes);
234+
228235
return this;
229236
}
230237

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using DockerComposeBuilder.Model.Services;
2+
using System;
3+
using YamlDotNet.Core;
4+
using YamlDotNet.Core.Events;
5+
using YamlDotNet.Serialization;
6+
7+
namespace DockerComposeBuilder.Converters;
8+
9+
public class ServiceVolumeCollectionConverter : IYamlTypeConverter
10+
{
11+
private readonly ServiceVolumeConverter _itemConverter = new();
12+
13+
public bool Accepts(Type type) => typeof(ServiceVolumeCollection).IsAssignableFrom(type);
14+
15+
public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
16+
{
17+
if (parser.Current is not SequenceStart)
18+
{
19+
return null;
20+
}
21+
22+
parser.MoveNext();
23+
var collection = new ServiceVolumeCollection();
24+
25+
while (parser.Current is not SequenceEnd)
26+
{
27+
var item = _itemConverter.ReadYaml(parser, typeof(ServiceVolume), rootDeserializer);
28+
if (item is ServiceVolume volume)
29+
{
30+
collection.Add(volume);
31+
}
32+
}
33+
34+
parser.MoveNext();
35+
return collection;
36+
}
37+
38+
public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
39+
{
40+
if (value is not ServiceVolumeCollection collection)
41+
{
42+
return;
43+
}
44+
45+
emitter.Emit(new SequenceStart(AnchorName.Empty, TagName.Empty, false, SequenceStyle.Block));
46+
47+
foreach (var item in collection)
48+
{
49+
_itemConverter.WriteYaml(emitter, item, typeof(ServiceVolume), serializer);
50+
}
51+
52+
emitter.Emit(new SequenceEnd());
53+
}
54+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using DockerComposeBuilder.Model.Services;
2+
using System;
3+
using YamlDotNet.Core;
4+
using YamlDotNet.Core.Events;
5+
using YamlDotNet.Serialization;
6+
7+
namespace DockerComposeBuilder.Converters;
8+
9+
public class ServiceVolumeConverter : IYamlTypeConverter
10+
{
11+
public bool Accepts(Type type) => typeof(ServiceVolume).IsAssignableFrom(type);
12+
13+
public object? ReadYaml(IParser parser, Type type, ObjectDeserializer rootDeserializer)
14+
{
15+
if (parser.Current is Scalar scalar)
16+
{
17+
var value = scalar.Value;
18+
parser.MoveNext();
19+
return ServiceVolume.FromShortSyntax(value);
20+
}
21+
22+
if (parser.Current is MappingStart)
23+
{
24+
return rootDeserializer(typeof(ServiceVolume));
25+
}
26+
27+
parser.MoveNext();
28+
return null;
29+
}
30+
31+
public void WriteYaml(IEmitter emitter, object? value, Type type, ObjectSerializer serializer)
32+
{
33+
if (value is ServiceVolume serviceVolume)
34+
{
35+
if (serviceVolume.ShortSyntax != null)
36+
{
37+
emitter.Emit(new Scalar(AnchorName.Empty, TagName.Empty, serviceVolume.ShortSyntax, ScalarStyle.DoubleQuoted, true, false));
38+
}
39+
else
40+
{
41+
serializer(value, type);
42+
}
43+
}
44+
}
45+
}

src/DockerComposeBuilder/Extensions/ComposeExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ string lineEndings
1515
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
1616
.WithTypeConverter(new YamlValueCollectionConverter())
1717
.WithTypeConverter(new PublishedPortConverter())
18+
.WithTypeConverter(new ServiceVolumeCollectionConverter())
1819
.WithNamingConvention(UnderscoredNamingConvention.Instance)
1920
.WithEventEmitter(nextEmitter => new FlowStyleStringSequences(nextEmitter))
2021
.WithEventEmitter(nextEmitter => new FlowStringEnumConverter(nextEmitter))
@@ -36,6 +37,7 @@ public static DeserializerBuilder CreateDeserializerBuilder(
3637
{
3738
var builder = new DeserializerBuilder()
3839
.WithTypeConverter(new PublishedPortConverter())
40+
.WithTypeConverter(new ServiceVolumeCollectionConverter())
3941
.WithNamingConvention(UnderscoredNamingConvention.Instance);
4042

4143
if (ignoreUnmatchedProperties)

src/DockerComposeBuilder/Model/Service.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public class Service : IObject
4141
public IDictionary<string, string?>? Environment { get; set; }
4242

4343
[YamlMember(Alias = "volumes")]
44-
public List<string>? Volumes { get; set; }
44+
public ServiceVolumeCollection? Volumes { get; set; }
4545

4646
[YamlMember(Alias = "ports")]
4747
public List<Port>? Ports { get; set; }

0 commit comments

Comments
 (0)