Skip to content

Commit c9d7cf7

Browse files
authored
Introduce PropertyFormat to avoid validation errors that can lead to internal exceptions (#248)
1 parent 8a3258e commit c9d7cf7

File tree

27 files changed

+243
-35
lines changed

27 files changed

+243
-35
lines changed

backend/src/Notifo.Domain.Integrations.Abstractions/IntegrationProperty.cs

+28
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public sealed record IntegrationProperty(string Name, PropertyType Type)
3939

4040
public string? Pattern { get; init; }
4141

42+
public PropertyFormat Format { get; init; } = PropertyFormat.None;
43+
4244
public bool IsValid(string? input, [MaybeNullWhen(true)] out string error)
4345
{
4446
switch (Type)
@@ -170,6 +172,32 @@ private bool TryGetString(string? input, [MaybeNullWhen(true)] out string error,
170172
}
171173
}
172174

175+
if (!string.IsNullOrWhiteSpace(input) && Format != PropertyFormat.None)
176+
{
177+
switch (Format)
178+
{
179+
case PropertyFormat.Email:
180+
if (!Regex.IsMatch(input, ValidationPatterns.Email))
181+
{
182+
error = Texts.IntegrationPropertyFormatEmail;
183+
return false;
184+
}
185+
186+
break;
187+
case PropertyFormat.HttpUrl:
188+
// We only allow "http" and "https" schemas to enable the usage of URL field for HttpClient requests.
189+
if (!Uri.TryCreate(input, UriKind.Absolute, out var uri)
190+
|| (!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) && !string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase))
191+
)
192+
{
193+
error = Texts.IntegrationPropertyFormatHttpUrl;
194+
return false;
195+
}
196+
197+
break;
198+
}
199+
}
200+
173201
return true;
174202
}
175203

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// ==========================================================================
2+
// Notifo.io
3+
// ==========================================================================
4+
// Copyright (c) Sebastian Stehle
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
namespace Notifo.Domain.Integrations;
9+
10+
public enum PropertyFormat
11+
{
12+
None,
13+
Email,
14+
HttpUrl
15+
}

backend/src/Notifo.Domain.Integrations.Abstractions/Resources/Texts.Designer.cs

+18
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/src/Notifo.Domain.Integrations.Abstractions/Resources/Texts.resx

+6
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@
120120
<data name="IntegrationPropertyAllowedValue" xml:space="preserve">
121121
<value>Field is not an allowed value.</value>
122122
</data>
123+
<data name="IntegrationPropertyFormatEmail" xml:space="preserve">
124+
<value>Field is not a valid e-mail.</value>
125+
</data>
126+
<data name="IntegrationPropertyFormatHttpUrl" xml:space="preserve">
127+
<value>Field is not a valid URL (remember about the protocol - http or https).</value>
128+
</data>
123129
<data name="IntegrationPropertyInvalidBoolean" xml:space="preserve">
124130
<value>Not a valid boolean value.</value>
125131
</data>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// ==========================================================================
2+
// Notifo.io
3+
// ==========================================================================
4+
// Copyright (c) Sebastian Stehle
5+
// All rights reserved. Licensed under the MIT license.
6+
// ==========================================================================
7+
8+
namespace Notifo.Domain.Integrations;
9+
10+
public static class ValidationPatterns
11+
{
12+
// Taken from Yup's source code (validation library used on the front-end side).
13+
public const string Email = @"^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$";
14+
}

backend/src/Notifo.Domain.Integrations/AmazonSES/IntegratedAmazonSESIntegration.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ public sealed class IntegratedAmazonSESIntegration : IIntegration, IInitializabl
2929

3030
public static readonly IntegrationProperty FromEmailProperty = new IntegrationProperty("fromEmail", PropertyType.Text)
3131
{
32-
Pattern = Patterns.Email,
3332
EditorLabel = Texts.Email_FromEmailLabel,
3433
EditorDescription = Texts.Email_FromEmailDescription,
3534
IsRequired = true,
36-
Summary = true
35+
Summary = true,
36+
Format = PropertyFormat.Email
3737
};
3838

3939
public static readonly IntegrationProperty FromNameProperty = new IntegrationProperty("fromName", PropertyType.Text)

backend/src/Notifo.Domain.Integrations/Http/HttpIntegration.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public sealed partial class HttpIntegration : IIntegration
1818
EditorLabel = Texts.Webhook_URLLabel,
1919
EditorDescription = Texts.Webhook_URLHints,
2020
IsRequired = true,
21-
Summary = true
21+
Summary = true,
22+
Format = PropertyFormat.HttpUrl,
2223
};
2324

2425
private static readonly IntegrationProperty HttpMethodProperty = new IntegrationProperty("Method", PropertyType.Text)

backend/src/Notifo.Domain.Integrations/Mailchimp/MailchimpIntegration.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ public sealed partial class MailchimpIntegration : IIntegration
2222

2323
public static readonly IntegrationProperty FromEmailProperty = new IntegrationProperty("fromEmail", PropertyType.Text)
2424
{
25-
Pattern = Patterns.Email,
2625
EditorLabel = Texts.Email_FromEmailLabel,
2726
EditorDescription = Texts.Email_FromEmailDescription,
2827
IsRequired = true,
29-
Summary = true
28+
Summary = true,
29+
Format = PropertyFormat.Email
3030
};
3131

3232
public static readonly IntegrationProperty FromNameProperty = new IntegrationProperty("fromName", PropertyType.Text)

backend/src/Notifo.Domain.Integrations/Mailjet/MailjetIntegration.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ public sealed partial class MailjetIntegration : IIntegration
2929

3030
public static readonly IntegrationProperty FromEmailProperty = new IntegrationProperty("fromEmail", PropertyType.Text)
3131
{
32-
Pattern = Patterns.Email,
3332
EditorLabel = Texts.Email_FromEmailLabel,
3433
EditorDescription = Texts.Email_FromEmailDescription,
3534
IsRequired = true,
36-
Summary = true
35+
Summary = true,
36+
Format = PropertyFormat.Email
3737
};
3838

3939
public static readonly IntegrationProperty FromNameProperty = new IntegrationProperty("fromName", PropertyType.Text)

backend/src/Notifo.Domain.Integrations/Patterns.cs

-13
This file was deleted.

backend/src/Notifo.Domain.Integrations/Resources/Texts.Designer.cs

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/src/Notifo.Domain.Integrations/Resources/Texts.resx

+1-1
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,7 @@ When you have done this send me an /update command.</value>
438438
<value>Send Always</value>
439439
</data>
440440
<data name="Webhook_URLHints" xml:space="preserve">
441-
<value>The URL to your server endpoint.</value>
441+
<value>The URL to your server endpoint. Remember about the protocol (http, https).</value>
442442
</data>
443443
<data name="Webhook_URLLabel" xml:space="preserve">
444444
<value>URL</value>

backend/src/Notifo.Domain.Integrations/Smtp/SmtpIntegration.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,11 @@ public sealed partial class SmtpIntegration : IIntegration
4242

4343
public static readonly IntegrationProperty FromEmailProperty = new IntegrationProperty("fromEmail", PropertyType.Text)
4444
{
45-
Pattern = Patterns.Email,
4645
EditorLabel = Texts.Email_FromEmailLabel,
4746
EditorDescription = Texts.Email_FromEmailDescription,
4847
IsRequired = true,
49-
Summary = true
48+
Summary = true,
49+
Format = PropertyFormat.Email,
5050
};
5151

5252
public static readonly IntegrationProperty FromNameProperty = new IntegrationProperty("fromName", PropertyType.Text)

backend/src/Notifo/Areas/Api/Controllers/Apps/Dtos/IntegrationPropertyDto.cs

+5
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ public sealed class IntegrationPropertyDto
7272
/// </summary>
7373
public string? Pattern { get; set; }
7474

75+
/// <summary>
76+
/// Format of the field, used to both validate the input and to provide hints to the user.
77+
/// </summary>
78+
public PropertyFormat Format { get; set; }
79+
7580
/// <summary>
7681
/// The default value.
7782
/// </summary>

backend/tests/Notifo.Domain.Tests/Integrations/IntegrationPropertyTests.cs

+71
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,77 @@ public void Should_not_fail_if_undefined_value_is_not_an_allowed_value(string? i
158158
Assert.Equal("allowed", property.GetString(source));
159159
}
160160

161+
[Theory]
162+
[InlineData("localhost.com/test")]
163+
[InlineData("192.168.0.101")]
164+
[InlineData("randomString")]
165+
public void Should_fail_if_url_is_invalid(string? input)
166+
{
167+
var source = new Dictionary<string, string>
168+
{
169+
["key"] = input!
170+
};
171+
172+
var property = new IntegrationProperty("key", PropertyType.Text)
173+
{
174+
Format = PropertyFormat.HttpUrl
175+
};
176+
177+
Assert.Throws<ValidationException>(() => property.GetString(source));
178+
}
179+
180+
[Theory]
181+
[InlineData("http://192.168.0.101/")]
182+
[InlineData("http://localhost/test")]
183+
[InlineData("https://example.com/test?query=example")]
184+
[InlineData("http://login:[email protected]/random")]
185+
public void Should_get_url_if_value_is_valid(string? input)
186+
{
187+
var source = new Dictionary<string, string>
188+
{
189+
["key"] = input!
190+
};
191+
192+
var property = new IntegrationProperty("key", PropertyType.Text)
193+
{
194+
Format = PropertyFormat.HttpUrl
195+
};
196+
197+
Assert.Equal(input, property.GetString(source));
198+
}
199+
200+
[Fact]
201+
public void Should_fail_if_email_is_invalid()
202+
{
203+
var source = new Dictionary<string, string>
204+
{
205+
["key"] = "invalidEmail"
206+
};
207+
208+
var property = new IntegrationProperty("key", PropertyType.Text)
209+
{
210+
Format = PropertyFormat.Email
211+
};
212+
213+
Assert.Throws<ValidationException>(() => property.GetString(source));
214+
}
215+
216+
[Fact]
217+
public void Should_get_email_if_value_is_valid()
218+
{
219+
var source = new Dictionary<string, string>
220+
{
221+
["key"] = "[email protected]"
222+
};
223+
224+
var property = new IntegrationProperty("key", PropertyType.Text)
225+
{
226+
Format = PropertyFormat.Email
227+
};
228+
229+
Assert.Equal(source["key"], property.GetString(source));
230+
}
231+
161232
[Fact]
162233
public void Should_get_value_if_all_requirements_are_met()
163234
{

frontend/src/app/pages/app/AppAuth.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const FormSchema = Yup.object().shape({
8686

8787
// Valid URL.
8888
signoutRedirectUrl: Yup.string()
89-
.label(texts.auth.signoutRedirectUrl).urlI18n(),
89+
.label(texts.auth.signoutRedirectUrl).httpUrlI18n(),
9090
});
9191

9292
const AuthForm = ({ appDetails, scheme }: { scheme?: AuthSchemeDto } & AppAuthProps) => {

frontend/src/app/pages/app/AppSettings.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const FormSchema = Yup.object().shape({
2828

2929
// Valid URL
3030
confirmUrl: Yup.string().nullable()
31-
.label(texts.app.confirmUrl).urlI18n(),
31+
.label(texts.app.confirmUrl).httpUrlI18n(),
3232
});
3333

3434
export interface AppSettingsProps {

frontend/src/app/pages/integrations/IntegrationDialog.tsx

+25
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,17 @@ export const IntegrationDialog = (props: IntegrationDialogProps) => {
124124
propertyType = propertyType.max(property.maxLength, texts.validation.maxLengthFn);
125125
}
126126

127+
if (property.format && property.format !== "None") {
128+
switch (property.format) {
129+
case "Email":
130+
propertyType = propertyType.emailI18n();
131+
break;
132+
case "HttpUrl":
133+
propertyType = propertyType.httpUrlI18n();
134+
break;
135+
}
136+
}
137+
127138
if (property.pattern) {
128139
propertyType = propertyType.matches(new RegExp(property.pattern), texts.validation.patternFn);
129140
}
@@ -271,6 +282,20 @@ export const FormField = ({ property }: { property: IntegrationPropertyDto }) =>
271282
label={label} hints={property.editorDescription} />
272283
);
273284
} else {
285+
if (property.format && property.format !== 'None') {
286+
switch (property.format) {
287+
case 'Email':
288+
return (
289+
<Forms.Email name={name}
290+
label={label} hints={property.editorDescription} />
291+
);
292+
case 'HttpUrl':
293+
return (
294+
<Forms.Url name={name}
295+
label={label} hints={property.editorDescription} />
296+
);
297+
}
298+
}
274299
return (
275300
<Forms.Text name={name}
276301
label={label} hints={property.editorDescription} />

frontend/src/app/pages/users/UserDialog.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export const UserDialog = (props: UserDialogProps) => {
119119
<Forms.Text name='emailAddress'
120120
label={texts.common.emailAddress} />
121121

122-
<Forms.Text name='phoneNumber'
122+
<Forms.Phone name='phoneNumber'
123123
label={texts.common.phoneNumber} />
124124

125125
<Forms.Select name='preferredLanguage' options={coreLanguages}

frontend/src/app/service/service.ts

+3
Original file line numberDiff line numberDiff line change
@@ -7430,11 +7430,14 @@ export interface IntegrationPropertyDto {
74307430
maxLength?: number | undefined;
74317431
/** The pattern (for strings). */
74327432
pattern?: string | undefined;
7433+
/** Format of the field, used to both validate the input and to provide hints to the user. */
7434+
format: PropertyFormat;
74337435
/** The default value. */
74347436
defaultValue?: any | undefined;
74357437
}
74367438

74377439
export type PropertyType = "Text" | "Number" | "MultilineText" | "Password" | "Boolean";
7440+
export type PropertyFormat = "None" | "Email" | "HttpUrl";
74387441

74397442
export interface IntegrationCreatedDto {
74407443
/** The ID of the integration. */

frontend/src/app/shared/components/Forms.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,14 @@ export module Forms {
177177
);
178178
};
179179

180+
export const Phone = ({ placeholder, ...other }: FormEditorProps) => {
181+
return (
182+
<Forms.Row {...other}>
183+
<InputSpecial type='tel' name={other.name} placeholder={placeholder} />
184+
</Forms.Row>
185+
);
186+
}
187+
180188
export const Url = ({ placeholder, ...other }: FormEditorProps) => {
181189
return (
182190
<Forms.Row {...other}>

0 commit comments

Comments
 (0)