├── img
├── mocking.jpg
├── mocking.png
├── recording.jpg
├── recording.png
├── verifying.jpg
├── customizations.png
├── failedVerification.png
├── simulatedConditions.png
└── successfulVerification.png
├── tests
├── integration
│ ├── docker-compose-api.yaml
│ ├── docker-compose-verify.yaml
│ ├── docker-compose-record.yaml
│ ├── docker-compose-mock.yaml
│ ├── Dockerfile
│ ├── util.ts
│ ├── consumer.ts
│ ├── api.ts
│ └── tests.ts
├── unit
│ ├── unit.csproj
│ └── InterpreterTests.cs
└── run_all_tests.ts
├── src
├── Dockerfile
├── Rumpel.csproj
├── Server.cs
├── Models.cs
├── Printer.cs
├── Verifier.cs
├── Main.cs
├── Recorder.cs
├── Mocker.cs
└── Interpreter.cs
├── LICENSE
├── readme.md
└── .gitignore
/img/mocking.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hellgrenj/Rumpel/HEAD/img/mocking.jpg
--------------------------------------------------------------------------------
/img/mocking.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hellgrenj/Rumpel/HEAD/img/mocking.png
--------------------------------------------------------------------------------
/img/recording.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hellgrenj/Rumpel/HEAD/img/recording.jpg
--------------------------------------------------------------------------------
/img/recording.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hellgrenj/Rumpel/HEAD/img/recording.png
--------------------------------------------------------------------------------
/img/verifying.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hellgrenj/Rumpel/HEAD/img/verifying.jpg
--------------------------------------------------------------------------------
/img/customizations.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hellgrenj/Rumpel/HEAD/img/customizations.png
--------------------------------------------------------------------------------
/img/failedVerification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hellgrenj/Rumpel/HEAD/img/failedVerification.png
--------------------------------------------------------------------------------
/img/simulatedConditions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hellgrenj/Rumpel/HEAD/img/simulatedConditions.png
--------------------------------------------------------------------------------
/img/successfulVerification.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hellgrenj/Rumpel/HEAD/img/successfulVerification.png
--------------------------------------------------------------------------------
/tests/integration/docker-compose-api.yaml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | api:
4 | build:
5 | context: .
6 | dockerfile: Dockerfile
7 | ports:
8 | - '1337:1337'
9 | networks:
10 | - integration-test-nw
11 | networks:
12 | integration-test-nw:
13 | name: integration-test-nw
14 |
15 |
--------------------------------------------------------------------------------
/src/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env
2 | WORKDIR /app
3 | COPY ./Rumpel.csproj .
4 | RUN dotnet restore
5 | COPY . .
6 | RUN dotnet publish -c Release -o out
7 |
8 | FROM mcr.microsoft.com/dotnet/aspnet:6.0
9 | WORKDIR /app
10 | COPY --from=build-env /app/out ./
11 | ENTRYPOINT ["dotnet", "./Rumpel.dll"]
12 |
--------------------------------------------------------------------------------
/src/Rumpel.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/tests/integration/docker-compose-verify.yaml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | rumpel:
4 | build: ../../src
5 | command: --verify-contract --contract-path=./contracts/consumer-api.rumpel.contract.json --bearer-token=$BEARER_TOKEN
6 | ports:
7 | - '8181:8181'
8 | volumes:
9 | - ./contracts/:/app/contracts
10 | networks:
11 | - integration-test-nw
12 | networks:
13 | integration-test-nw:
14 | name: integration-test-nw
15 |
--------------------------------------------------------------------------------
/tests/integration/docker-compose-record.yaml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | rumpel:
4 | environment:
5 | - RUMPEL_PORT=8585
6 | build: ../../src
7 | command: --record-contract --target-api=http://api:1337 --contract-name=consumer-api
8 | ports:
9 | - '8585:8585'
10 | volumes:
11 | - ./contracts/:/app/contracts
12 | networks:
13 | - integration-test-nw
14 | networks:
15 | integration-test-nw:
16 | name: integration-test-nw
17 |
--------------------------------------------------------------------------------
/tests/integration/docker-compose-mock.yaml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | rumpel:
4 | environment:
5 | - RUMPEL_PORT=8585
6 | build: ../../src
7 | command: --mock-provider --contract-path=./contracts/consumer-api.rumpel.contract.json
8 | ports:
9 | - '8585:8585'
10 | volumes:
11 | - ./contracts/:/app/contracts
12 | networks:
13 | - integration-test-nw
14 | networks:
15 | integration-test-nw:
16 | name: integration-test-nw
17 |
18 |
--------------------------------------------------------------------------------
/tests/integration/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM denoland/deno:1.13.2
2 |
3 | EXPOSE 1337
4 |
5 | WORKDIR /app
6 |
7 | USER deno
8 |
9 | # Cache the dependencies as a layer (the following two steps are re-run only when deps.ts is modified).
10 | # Ideally cache deps.ts will download and compile _all_ external files used in main.ts.
11 | # COPY deps.ts .
12 | # RUN deno cache deps.ts
13 |
14 | COPY api.ts .
15 | # Compile the main app so that it doesn't need to be compiled each startup/entry.
16 | RUN deno cache api.ts
17 |
18 | CMD ["run", "--allow-net", "api.ts"]
--------------------------------------------------------------------------------
/tests/integration/util.ts:
--------------------------------------------------------------------------------
1 |
2 | const sleep = (ms: number) => {
3 | return new Promise((resolve) => {
4 | setTimeout(() => {
5 | resolve("done");
6 | }, ms);
7 | });
8 | };
9 | export const waitForEndpoint = async (url: string): Promise => {
10 | try {
11 | const response = await fetch(url, { method: "GET" });
12 | if (response.status !== 200) {
13 | throw new Error(`status code ${response.status}`);
14 | } else {
15 | console.log(`${url} ready, moving on..`);
16 | return;
17 | }
18 | } catch {
19 | await sleep(1000);
20 | return waitForEndpoint(url);
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/tests/unit/unit.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net6.0
5 |
6 | false
7 |
8 |
9 |
10 |
11 |
12 |
13 | runtime; build; native; contentfiles; analyzers; buildtransitive
14 | all
15 |
16 |
17 | runtime; build; native; contentfiles; analyzers; buildtransitive
18 | all
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Johan Hellgren
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/run_all_tests.ts:
--------------------------------------------------------------------------------
1 | import * as Colors from "https://deno.land/std@0.95.0/fmt/colors.ts";
2 |
3 | let unitTestsPassed = false;
4 | let integrationTestsPassed = false;
5 | console.log(Colors.gray("running unit tests"));
6 | const unitTests = Deno.run({
7 | cmd: ["dotnet", "test"],
8 | cwd: "./unit",
9 | stdout: "piped",
10 | stdin: "piped",
11 | stderr: "piped",
12 | });
13 | const unitResult = new TextDecoder().decode(await unitTests.output());
14 | if (unitResult.includes("Passed!")) {
15 | unitTestsPassed = true;
16 | }
17 |
18 | console.log(Colors.gray("running integration tests, this can take a minute.."));
19 | const integrationTests = Deno.run({
20 | cmd: ["deno", "run", "-A", "tests.ts"],
21 | cwd: "./integration",
22 | stdout: "piped",
23 | stdin: "piped",
24 | stderr: "piped",
25 | });
26 | const integrationResult = new TextDecoder().decode(
27 | await integrationTests.output(),
28 | );
29 | if (integrationResult.includes("integration tests passed!".toUpperCase())) {
30 | integrationTestsPassed = true;
31 | }
32 |
33 | if (unitTestsPassed && integrationTestsPassed) {
34 | console.log(Colors.green("✅ Both unit tests and integration tests passed!".toUpperCase()));
35 | } else {
36 | if (!unitTestsPassed) {
37 | console.log(unitResult);
38 | console.log(Colors.red("❌ Unit tests failed!".toUpperCase()));
39 | }
40 | if (!integrationTestsPassed) {
41 | console.log(integrationResult);
42 | console.log(Colors.red("❌ integration tests failed!".toUpperCase()));
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/Server.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Threading.Tasks;
3 | using Microsoft.AspNetCore.Builder;
4 | using Microsoft.AspNetCore.Hosting;
5 | using Microsoft.AspNetCore.Http;
6 | using Microsoft.Extensions.DependencyInjection;
7 | using Microsoft.Extensions.Logging;
8 | public static class Server
9 | {
10 | public static async Task Start(Func requestHandler)
11 | {
12 | var port = Environment.GetEnvironmentVariable("RUMPEL_PORT") != null ? Convert.ToInt32(Environment.GetEnvironmentVariable("RUMPEL_PORT")) : 8181;
13 | Printer.PrintInfo($"rumpel up and listening on port {port}");
14 | var builder = WebApplication.CreateBuilder();
15 | builder.Services.AddCors(o => o.AddPolicy("corsPolicy", builder =>
16 | {
17 | builder.AllowAnyOrigin()
18 | .AllowAnyMethod()
19 | .AllowAnyHeader();
20 | }));
21 | builder.Services.AddLogging(config => config.ClearProviders());
22 | builder.WebHost.ConfigureKestrel(ko =>
23 | {
24 | ko.Limits.MinRequestBodyDataRate = null;
25 | ko.ListenAnyIP(port);
26 | });
27 | var app = builder.Build();
28 | app.UseCors("corsPolicy");
29 | app.Use((context, next) =>
30 | {
31 | context.Request.EnableBuffering();
32 | return next();
33 | });
34 | app.Map("{**path}", async context => await requestHandler(context));
35 | await app.RunAsync();
36 | }
37 | }
--------------------------------------------------------------------------------
/src/Models.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Net.Http;
4 | using System.Reflection;
5 |
6 | namespace Rumpel.Models;
7 | public static class IgnoreFlags
8 | {
9 | public const string IgnoreAssertStatusCode = "--ignore-assert-status-code";
10 | public const string IgnoreAssertArrayLength = "--ignore-assert-array-length";
11 |
12 | public static List ToList()
13 | {
14 | var list = new List();
15 | Type t = typeof(IgnoreFlags);
16 | var fields = t.GetFields(BindingFlags.Static | BindingFlags.Public);
17 | foreach (var f in fields)
18 | {
19 | list.Add((f.GetValue(null).ToString()));
20 | }
21 | return list;
22 | }
23 | public static bool IsValid(string flag)
24 | {
25 | return ToList().Contains(flag);
26 | }
27 | }
28 | public record Contract
29 | {
30 | public string Name { get; init; }
31 | public string URL { get; init; }
32 | public List Transactions { get; init; } = new();
33 | }
34 | public class Transaction
35 | {
36 | public Request Request { get; set; }
37 | public Response Response { get; set; }
38 | public List Customizations { get; set; } = new();
39 | public List SimulatedConditions { get; set; } = new();
40 | }
41 | public class Request
42 | {
43 | public string Path { get; set; }
44 | public string Method { get; set; }
45 | public string RawBody { get; set; }
46 | public Dictionary> Headers { get; set; } = new();
47 |
48 | public void AddHeaders(HttpRequestMessage httpReq)
49 | {
50 | foreach (var reqHeader in httpReq.Headers)
51 | {
52 | this.Headers.Add(reqHeader.Key, reqHeader.Value);
53 | }
54 | }
55 |
56 | }
57 | public class Response
58 | {
59 | public int StatusCode { get; set; }
60 | public Dictionary> Headers { get; set; } = new();
61 | public string RawBody { get; set; }
62 |
63 | public void AddHeaders(HttpResponseMessage httpResp)
64 | {
65 | foreach (var respHeader in httpResp.Headers)
66 | {
67 | this.Headers.Add(respHeader.Key, respHeader.Value);
68 | }
69 | }
70 | }
71 | public record Customization(string PropertyName, int Depth, string Action);
72 | public class CustomizationActions
73 | {
74 | public const string IgnoreObjectProperty = "IgnoreObjectProperty";
75 | public const string CompareObjectPropertyValues = "CompareObjectPropertyValues";
76 | }
77 | public class SimulatedConditionTypes
78 | {
79 | public const string FixedDelay = "FixedDelay";
80 | public const string RandomDelay = "RandomDelay";
81 | public const string Sometimes500 = "Sometimes500";
82 | }
83 | public record SimulatedCondition(string Type, string Value);
84 |
85 |
86 |
--------------------------------------------------------------------------------
/src/Printer.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using Rumpel.Models;
3 |
4 | public static class Printer
5 | {
6 | public static void PrintInfo(string msg)
7 | {
8 | Console.ForegroundColor = ConsoleColor.Blue;
9 | Console.WriteLine(msg);
10 | Console.ResetColor();
11 | }
12 |
13 | public static void PrintOK(string msg)
14 | {
15 | Console.ForegroundColor = ConsoleColor.Green;
16 | Console.WriteLine(msg);
17 | Console.ResetColor();
18 | }
19 | public static void PrintErr(string err)
20 | {
21 | Console.ForegroundColor = ConsoleColor.Red;
22 | Console.WriteLine(err);
23 | Console.ResetColor();
24 | }
25 | public static void PrintWarning(string warning)
26 | {
27 | Console.ForegroundColor = ConsoleColor.Yellow;
28 | Console.WriteLine(warning);
29 | Console.ResetColor();
30 | }
31 |
32 | public static void PrintHelp()
33 | {
34 | var header = ".::Rumpel::.";
35 | var subHeader = "Simple, opinionated and automated consumer-driven contract testing for your JSON API's";
36 | var info = @"
37 | HELP:
38 |
39 | --help (or the shorthand -h): Displays this information.
40 |
41 | --version: Prints the version
42 |
43 | --record-contract (or the shorthand -r): This starts a new recording of a new contract.
44 | The expected arguments are: --record-contract|-r --contract-name= --target-api=
45 |
46 | --verify-contract (or the shorthand -v): This verifies (tests) a contract.
47 | The expected arguments are: --verify-contract|-v --contract-path= (ignore flags) (--bearer-token=) (--base-url=)
48 | (ignore flags, bearer token and base url are optional)
49 |
50 | --mock-provider (or the shorthand -m): This mocks a provider based on a contract.
51 | The expected arguments are: --mock-provider|-m --contract-path=";
52 |
53 | var ignoreFlagsInfo = @"ignoreFlags =
54 |
55 | The verifyer can be told to ignore specific assertions.
56 | Example with ignore flags:
57 | --verify-contract|-v --contract-path= --ignore-assert-status-code
58 |
59 | These are the available ignoreFlags:";
60 |
61 | var readMoreInfo = @"
62 |
63 | You can customize the verification (per transaction) when verifying a contract as well as
64 | simulate different conditions when mocking a provider.
65 | Read about this and more at https://github.com/hellgrenj/Rumpel";
66 |
67 |
68 | Console.ForegroundColor = ConsoleColor.DarkYellow;
69 | Console.WriteLine(header);
70 | Console.WriteLine(subHeader);
71 | Console.ResetColor();
72 | Console.WriteLine(info);
73 | Console.WriteLine(ignoreFlagsInfo);
74 | IgnoreFlags.ToList().ForEach(f => Console.WriteLine(f));
75 | Console.ForegroundColor = ConsoleColor.DarkYellow;
76 | Console.WriteLine(readMoreInfo);
77 | Console.ResetColor();
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/tests/integration/consumer.ts:
--------------------------------------------------------------------------------
1 | import * as Colors from "https://deno.land/std@0.95.0/fmt/colors.ts";
2 | console.log(Colors.yellow("started to simulate consumer"));
3 |
4 | const baseUrl = "http://localhost:8585";
5 |
6 | const getAllCakesResponse = await fetch(`${baseUrl}/cakes`, {
7 | method: "GET",
8 | });
9 | if (getAllCakesResponse.status !== 200) {
10 | if (getAllCakesResponse.status === 500) {
11 | console.log(
12 | `getAllCakes received status code 500, which is OK since we simulated this with SimulatedConditions, see contract`,
13 | );
14 | } else {
15 | throw new Error("getAllCakes req failed");
16 | }
17 | } else {
18 | console.log("getAllCakes req succeeded");
19 | }
20 |
21 | const token = Deno.env.get("BEARER_TOKEN");
22 | const getAllSecretCakesResponse = await fetch(`${baseUrl}/scakes`, {
23 | method: "GET",
24 | headers: {
25 | "Authorization": "Bearer " + token,
26 | },
27 | });
28 | if (getAllSecretCakesResponse.status !== 200) {
29 | throw new Error("getAllSecretCakesResponse req failed");
30 | } else {
31 | console.log(
32 | `getAllSecretCakesResponse req succeeded with Bearer token ${token}`,
33 | );
34 | }
35 |
36 | const createCakeResponse = await fetch(`${baseUrl}/cakes`, {
37 | method: "POST",
38 | headers: {
39 | "Content-Type": "application/json",
40 | },
41 | body: JSON.stringify({
42 | name: "raspberry sensation",
43 | ingredients: ["sugar", "love"],
44 | }),
45 | });
46 | if (createCakeResponse.status !== 201) {
47 | throw new Error("createCakeResponse req failed");
48 | } else {
49 | console.log("createCakeResponse req succeeded");
50 | }
51 |
52 | const getAlLCakesResponseAgain = await fetch(`${baseUrl}/cakes`, {
53 | method: "GET",
54 | });
55 | if (getAlLCakesResponseAgain.status !== 200) {
56 | if (getAlLCakesResponseAgain.status === 500) {
57 | console.log(
58 | `getAlLCakesResponseAgain received status code 500, which is OK since we simulated this with SimulatedConditions, see contract`,
59 | );
60 | } else {
61 | throw new Error("getAlLCakesResponseAgain req failed");
62 | }
63 | } else {
64 | console.log("getAlLCakesResponseAgain req succeeded");
65 | }
66 |
67 | const replaceCakeResponse = await fetch(`${baseUrl}/cakes/1`, {
68 | method: "PUT",
69 | headers: {
70 | "Content-Type": "application/json",
71 | },
72 | body: JSON.stringify({
73 | name: "raspberry sensation2",
74 | ingredients: ["sugar", "love", "pineapple"],
75 | }),
76 | });
77 | if (replaceCakeResponse.status !== 200) {
78 | throw new Error("replaceCakeResponse request failed");
79 | } else {
80 | console.log("replaceCakeResponse req succeeded");
81 | }
82 |
83 | const updateCakeResponse = await fetch(`${baseUrl}/cakes/1`, {
84 | method: "PATCH",
85 | headers: {
86 | "Content-Type": "application/json",
87 | },
88 | body: JSON.stringify({
89 | name: "raspberry sensation2021",
90 | }),
91 | });
92 | if (updateCakeResponse.status !== 200) {
93 | throw new Error("updateCakeResponse request failed");
94 | } else {
95 | console.log("updateCakeResponse req succeeded");
96 | }
97 |
98 | const getSingleCakeByIdResponse = await fetch(`${baseUrl}/cakes/1`, {
99 | method: "GET",
100 | headers: {
101 | "Content-Type": "application/json",
102 | },
103 | });
104 | if (getSingleCakeByIdResponse.status !== 200) {
105 | throw new Error("getSingleCakeByIdResponse request failed");
106 | } else {
107 | console.log("getSingleCakeByIdResponse req succeeded");
108 | }
109 |
110 | const getSingleCakeByQueryParamResponse = await fetch(
111 | `${baseUrl}/cakeByQuery?id=1`,
112 | {
113 | method: "GET",
114 | headers: {
115 | "Content-Type": "application/json",
116 | },
117 | },
118 | );
119 | if (getSingleCakeByQueryParamResponse.status !== 200) {
120 | throw new Error("getSingleCakeByQueryParamResponse request failed");
121 | } else {
122 | console.log("getSingleCakeByQueryParamResponse req succeeded");
123 | }
124 |
125 | const deleteCakeResponse = await fetch(`${baseUrl}/cakes/1`, {
126 | method: "DELETE",
127 | headers: {
128 | "Content-Type": "application/json",
129 | },
130 | });
131 | if (deleteCakeResponse.status !== 200) {
132 | throw new Error("deleteCakeResponse request failed");
133 | } else {
134 | console.log("deleteCakeResponse req succeeded");
135 | }
136 | console.log(Colors.yellow("consumer simulation done"));
137 |
--------------------------------------------------------------------------------
/src/Verifier.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Linq;
5 | using System.Net.Http;
6 | using System.Net.Http.Headers;
7 | using System.Text;
8 | using System.Threading.Tasks;
9 | using Microsoft.AspNetCore.Http;
10 | using Rumpel.Models;
11 |
12 | public class Verifier
13 | {
14 | private static HttpClient _httpClient = new HttpClient();
15 | private Contract _contract;
16 | private List _ignoreFlags;
17 |
18 |
19 | public Verifier(Contract contract, List ignoreFlags, string bearerToken, string url)
20 | {
21 | _contract = contract;
22 | _ignoreFlags = ignoreFlags;
23 |
24 | if (!String.IsNullOrEmpty(bearerToken))
25 | {
26 | Printer.PrintInfo($"adding bearer token to all requests (token: {bearerToken})");
27 | _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
28 | }
29 | if (!String.IsNullOrEmpty(url))
30 | {
31 | Printer.PrintInfo($"overriding recorded url {contract.URL} with {url}");
32 | _contract = contract with { URL = url };
33 | }
34 | }
35 | public async Task Verify()
36 | {
37 | var verificationSucceeded = true;
38 | Printer.PrintInfo($"verifying contract {_contract.Name}");
39 | foreach (var trans in _contract.Transactions)
40 | {
41 |
42 | using var outboundRequest = InitiateOutboundRequest(trans.Request);
43 | CopyHeadersAndContentFromRequest(trans.Request, outboundRequest);
44 | using var responseMessage = await _httpClient.SendAsync(outboundRequest);
45 | try
46 | {
47 | var (isValid, errorMessages) = await ValidateResponse(responseMessage, trans, _ignoreFlags);
48 | if (isValid)
49 | Printer.PrintOK($"✅ {trans.Request.Method} {trans.Request.Path}");
50 | else
51 | {
52 | verificationSucceeded = false;
53 | errorMessages.ForEach(error => Printer.PrintErr($"❌ {trans.Request.Method} {trans.Request.Path} failed with error: {error}"));
54 | }
55 | }
56 | catch (Exception e)
57 | {
58 | verificationSucceeded = false;
59 | Printer.PrintErr($"Failed to handle {trans.Request.Method} {trans.Request.Path}: {e.Message}");
60 | }
61 | }
62 | return verificationSucceeded;
63 | }
64 | private async Task<(bool, List)> ValidateResponse(HttpResponseMessage responseMessage, Transaction trans, List ignoreFlags)
65 | {
66 | var isValid = true;
67 | var errorMessages = new List();
68 | var jsonString = await responseMessage.Content.ReadAsStringAsync();
69 | var responseStatusCode = (int)responseMessage.StatusCode;
70 | if (trans.Response.StatusCode != responseStatusCode && !ignoreFlags.Contains(IgnoreFlags.IgnoreAssertStatusCode))
71 | {
72 | isValid = false;
73 | errorMessages.Add($@"request {trans.Request.Method} {trans.Request.Path}
74 | received status code {responseStatusCode}
75 | but expected status code { trans.Response.StatusCode}
76 | ");
77 | }
78 | var (passesSchemaValidation, schemaErrorMessages) = Interpreter.InferSchemaAndValidate(jsonString, trans.Response.RawBody, ignoreFlags, trans.Customizations);
79 | if (!passesSchemaValidation)
80 | {
81 | isValid = false;
82 | errorMessages.AddRange(schemaErrorMessages);
83 | }
84 | return (isValid, errorMessages);
85 |
86 | }
87 | private HttpRequestMessage InitiateOutboundRequest(Request rumpelReq)
88 | {
89 | var outboundRequest = new HttpRequestMessage();
90 | outboundRequest.RequestUri = new Uri(_contract.URL + rumpelReq.Path);
91 | outboundRequest.Method = new HttpMethod(rumpelReq.Method);
92 | return outboundRequest;
93 | }
94 | private void CopyHeadersAndContentFromRequest(Request rumpelReq, HttpRequestMessage requestMessage)
95 | {
96 |
97 | var requestMethod = rumpelReq.Method;
98 | if (HttpMethods.IsPost(requestMethod) ||
99 | HttpMethods.IsPut(requestMethod) ||
100 | HttpMethods.IsPatch(requestMethod))
101 | {
102 | var streamContent = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes(rumpelReq.RawBody)));
103 | requestMessage.Content = streamContent;
104 | }
105 | foreach (var header in rumpelReq.Headers)
106 | {
107 | if (header.Key.ToLower() == "host")
108 | continue;
109 |
110 | if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()))
111 | {
112 | requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
113 | }
114 | }
115 | // finally set our target host
116 | requestMessage.Headers.Host = new Uri(_contract.URL + rumpelReq.Path).Host;
117 | }
118 |
119 |
120 | }
--------------------------------------------------------------------------------
/tests/integration/api.ts:
--------------------------------------------------------------------------------
1 | import { Application } from "https://deno.land/x/oak@v7.7.0/mod.ts";
2 | import { Router } from "https://deno.land/x/oak@v7.7.0/mod.ts";
3 | import {
4 | create,
5 | getNumericDate,
6 | verify,
7 | } from "https://deno.land/x/djwt@v2.3/mod.ts";
8 |
9 | const key = await crypto.subtle.generateKey(
10 | { name: "HMAC", hash: "SHA-512" },
11 | true,
12 | ["sign", "verify"],
13 | );
14 |
15 | const getToken = async (
16 | { request, response }: { request: any; response: any },
17 | ) => {
18 | const token = await create(
19 | { alg: "HS512", typ: "JWT" },
20 | { exp: getNumericDate(60 * 60), foo: "bar" }, // valid for 1 hour...
21 | key,
22 | );
23 | response.body = token;
24 | };
25 |
26 | // API and in-memory data store of cakes..
27 | interface ICake {
28 | id: number;
29 | name?: string;
30 | ingredients: Array;
31 | }
32 |
33 | let nextId = 0;
34 | const getNextId = (): number => {
35 | nextId++;
36 | return nextId;
37 | };
38 | let cakes = new Array();
39 | const secureCakes = async (
40 | { request, response }: { request: any; response: any },
41 | ) => {
42 | const bearerToken = request.headers.get("Authorization");
43 | const jwt = bearerToken.slice(7); // strip away the "Bearer " part
44 | if (!jwt) {
45 | response.status = 401;
46 | } else {
47 | try {
48 | const payload = await verify(jwt, key);
49 | console.log(`received jwt ${JSON.stringify(payload)}`);
50 | const secureCake = {
51 | id: 1,
52 | name: "SecureCake",
53 | ingredients: "secure things",
54 | };
55 | response.status = 200;
56 | response.body = [secureCake];
57 | } catch (e) {
58 | console.log("failed with");
59 | console.log(e);
60 | response.status = 401;
61 | }
62 | }
63 | };
64 | const getCakes = (
65 | { request, response }: { request: any; response: any },
66 | ) => {
67 | response.body = cakes;
68 | };
69 | const getCake = (
70 | { params, response }: { params: any; response: any },
71 | ) => {
72 | const id: number = params.id;
73 | const cake = cakes.filter((c) => c.id == id)[0];
74 | console.log('V1 returning cake', cake);
75 | response.status = 200;
76 | response.body = cake;
77 | };
78 | const getCakeQuery = (
79 | ctx: any,
80 | ) => {
81 |
82 | const id: number = ctx.request.url.searchParams.get('id');
83 | console.log('searching for cake with id ', id);
84 | const cake = cakes.filter((c) => c.id == id)[0];
85 | console.log('getCakeQuery returning cake', cake);
86 | if(!cake)
87 | {
88 | ctx.response.status = 404;
89 | ctx.response.body = {};
90 | } else {
91 | ctx.response.status = 200;
92 | ctx.response.body = cake;
93 | }
94 |
95 | };
96 | const getCakeV2 = (
97 | { params, response }: { params: any; response: any },
98 | ) => {
99 | const id: number = params.id;
100 | const cake = cakes.filter((c) => c.id == id)[0];
101 | const modifiedCake = {...cake};
102 | delete modifiedCake.name;
103 | console.log('V2 returning cake', modifiedCake);
104 | response.status = 200;
105 | response.body = modifiedCake;
106 | };
107 | const addCake = async (
108 | { request, response }: { request: any; response: any },
109 | ) => {
110 | const body = await request.body();
111 | const cake: ICake = await body.value;
112 | cake.id = getNextId();
113 | cakes.push(cake);
114 | response.status = 201;
115 | response.body = JSON.stringify(cake);
116 | };
117 | const deleteCake = (
118 | { params, response }: { params: any; response: any },
119 | ) => {
120 | const id: number = params.id;
121 |
122 | cakes = cakes.filter((c) => c.id != id);
123 | response.status = 200;
124 | };
125 | const replaceCake = async (
126 | { request, params, response }: { request: any; params: any; response: any },
127 | ) => {
128 | const id: number = params.id;
129 | const body = await request.body();
130 | const cake: ICake = await body.value;
131 | cake.id = id;
132 |
133 | cakes = cakes.filter((c) => c.id != cake.id);
134 | cakes.push(cake);
135 | response.status = 200;
136 | response.body = JSON.stringify(cake.id);
137 | };
138 | const updateCake = async (
139 | { request, params, response }: { request: any; params: any; response: any },
140 | ) => {
141 | const id: number = params.id;
142 | const body = await request.body();
143 | const changes: Record = await body.value;
144 | const cake = cakes.filter((c) => c.id == id)[0];
145 | const c = cake as Record;
146 | for (const k in changes) {
147 | c[k] = changes[k];
148 | }
149 | cakes = cakes.filter((c) => c.id != id);
150 | cakes.push(c as ICake);
151 | response.status = 200;
152 | };
153 | const app = new Application();
154 | const router = new Router();
155 | router.get("/health", ({ response }: { response: any }) => {
156 | response.status = 200;
157 | });
158 | router.get("/token",getToken);
159 | router.get("/scakes", secureCakes);
160 | router.get("/cakes", getCakes);
161 | router.get("/cakes/:id", getCake);
162 | router.get("/cakeByQuery", getCakeQuery);
163 | router.get("/V2/cakes/:id", getCakeV2);
164 | router.post("/cakes", addCake);
165 | router.delete("/cakes/:id", deleteCake);
166 | router.put("/cakes/:id", replaceCake);
167 | router.patch("/cakes/:id", updateCake);
168 |
169 | app.use(router.routes());
170 | app.use(router.allowedMethods());
171 | console.log(`Listening on port 1337 ...`);
172 | await app.listen({ port: 1337 });
173 |
--------------------------------------------------------------------------------
/src/Main.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Linq;
5 | using System.Text;
6 | using System.Text.Json;
7 | using System.Threading.Tasks;
8 | using Rumpel.Models;
9 |
10 | if (args.Length == 0)
11 | {
12 | Printer.PrintHelp();
13 | Environment.Exit(0);
14 | }
15 |
16 | switch (args[0])
17 | {
18 | case "--help":
19 | case "-h": Printer.PrintHelp(); break;
20 | case "--version": Printer.PrintInfo("Rumpel v0.4.2"); break;
21 | case "--record-contract":
22 | case "-r": await RecordContract(); break;
23 | case "--verify-contract":
24 | case "-v": await VerifyContract(); break;
25 | case "--mock-provider":
26 | case "-m": await MockProvider(); break;
27 | default: Printer.PrintErr($"unknown argument {args[0]}"); Environment.Exit(1); break;
28 | }
29 |
30 | async Task RecordContract()
31 | {
32 | ValidateArgs(expectedLength: 3, expecting: "--record-contract|-r --contract-name= --target-api=");
33 |
34 | var contractNamePrefix = "--contract-name=";
35 | var (contractName, contractNameExtracted) = TryExtractSetting(args, contractNamePrefix);
36 | if (!contractNameExtracted)
37 | ExitWithArgumentMissingOrInvalid(argumentName: "contract name", contractNamePrefix, expectedInput: "name");
38 |
39 | var targetApiPrefix = "--target-api=";
40 | var (targetApi, targetApiExtracted) = TryExtractSetting(args, targetApiPrefix);
41 | if (!targetApiExtracted)
42 | ExitWithArgumentMissingOrInvalid(argumentName: "target api", targetApiPrefix, expectedInput: "url");
43 |
44 | var contract = new Contract() { Name = contractName, URL = targetApi };
45 | var recorder = new Recorder(contract);
46 | await recorder.Record();
47 | }
48 | async Task VerifyContract()
49 | {
50 | ValidateArgs(expectedLength: 2, expecting: "--verify-contract|-v --contract-path= (ignore-flags) (--bearer-token=)");
51 |
52 | var (bearerToken, _) = TryExtractSetting(args, prefix: "--bearer-token=");
53 | var (baseUrl, _) = TryExtractSetting(args, prefix: "--base-url=");
54 |
55 | var contract = TryParseContract();
56 | var ignoreFlags = ExtractIgnoreFlags(args);
57 | var verifier = new Verifier(contract, ignoreFlags, bearerToken, baseUrl);
58 |
59 | var verificationSucceeded = await verifier.Verify();
60 | if (verificationSucceeded)
61 | {
62 | Printer.PrintOK($"\nContract test passed! (contract: {contract.Name})".ToUpper());
63 | }
64 | else
65 | {
66 | Printer.PrintErr($"\nContract test failed! (contract: {contract.Name})".ToUpper());
67 | Environment.Exit(1);
68 | }
69 | }
70 | async Task MockProvider()
71 | {
72 | ValidateArgs(expectedLength: 2, expecting: "--mock-provider|-m --contract-path=");
73 | var contract = TryParseContract();
74 | var mocker = new Mocker(contract);
75 | await mocker.Run();
76 | }
77 | void ValidateArgs(int expectedLength, string expecting)
78 | {
79 | if (args.Length < expectedLength)
80 | {
81 | Printer.PrintErr($"missing arguments, expecting: {expecting}");
82 | Environment.Exit(1);
83 | }
84 | }
85 | Contract TryParseContract()
86 | {
87 | var contractPathPrefix = "--contract-path=";
88 | var (contractPath, contractPathExtracted) = TryExtractSetting(args, contractPathPrefix);
89 | if (!contractPathExtracted)
90 | ExitWithArgumentMissingOrInvalid(argumentName: "contract path", contractPathPrefix, expectedInput: "path");
91 |
92 | Contract contract = null;
93 | try
94 | {
95 | contract = JsonSerializer.Deserialize(File.ReadAllText(contractPath, Encoding.UTF8));
96 | }
97 | catch (Exception ex)
98 | {
99 | Printer.PrintErr($"could not parse the provided contract: \n {ex.Message}");
100 | Environment.Exit(1);
101 | }
102 | return contract;
103 | }
104 | (string, bool) TryExtractSetting(string[] args, string prefix)
105 | {
106 | string setting;
107 | bool extracted;
108 | var argument = args.Where(a => a.Contains(prefix)).FirstOrDefault();
109 | if (String.IsNullOrEmpty(argument) || argument.Length < prefix.Length + 1)
110 | {
111 | setting = String.Empty;
112 | extracted = false;
113 | }
114 | else
115 | {
116 | setting = argument.Split(prefix)[1];
117 | extracted = true;
118 | }
119 |
120 | return (setting, extracted);
121 | }
122 | void ExitWithArgumentMissingOrInvalid(string argumentName, string prefix, string expectedInput)
123 | {
124 | Printer.PrintErr($" {argumentName} must be provided with the correct syntax {prefix}<{expectedInput}>");
125 | Environment.Exit(1);
126 | }
127 | List ExtractIgnoreFlags(string[] args)
128 | {
129 | var ignoreFlags = new List();
130 | ignoreFlags.AddRange(args.Where(a => a.Contains("--ignore-")).ToList());
131 |
132 | if (ignoreFlags.Count > 0)
133 | {
134 | Printer.PrintWarning("Running with the following ignore flags");
135 | ignoreFlags.ForEach(flag =>
136 | {
137 | if (IgnoreFlags.IsValid(flag))
138 | Printer.PrintWarning(flag);
139 | else
140 | Printer.PrintErr($"ignore flag {flag} is not valid");
141 | });
142 | }
143 | return ignoreFlags;
144 | }
145 |
146 |
--------------------------------------------------------------------------------
/src/Recorder.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IO;
3 | using System.Linq;
4 | using System.Net.Http;
5 | using System.Text;
6 | using System.Text.Json;
7 | using System.Threading.Tasks;
8 | using Microsoft.AspNetCore.Http;
9 | using Rumpel.Models;
10 |
11 | public class Recorder
12 | {
13 | private static HttpClient _httpClient = new HttpClient();
14 | private Contract _contract;
15 | public Recorder(Contract contract) => _contract = contract;
16 |
17 | public async Task Record()
18 | {
19 | Printer.PrintInfo($"recording a new contract with name {_contract.Name}, target url {_contract.URL}");
20 | await Server.Start(ProxyHandler);
21 | }
22 | async Task ProxyHandler(HttpContext context)
23 | {
24 |
25 | var trans = await InitiateNewTransaction(context);
26 |
27 | using var outboundRequest = InitiateOutboundRequest(trans);
28 | CopyHeadersAndContentFromRequest(context, outboundRequest, trans);
29 |
30 | using var responseMessage = await _httpClient.SendAsync(outboundRequest);
31 | await ReadResponse(trans, responseMessage);
32 | await SaveTransaction(trans);
33 | await CopyHeadersAndContentFromResponse(context, responseMessage);
34 |
35 | await context.Response.CompleteAsync();
36 | }
37 |
38 | private async Task InitiateNewTransaction(HttpContext context)
39 | {
40 |
41 | var path = context.Request.Path.ToString();
42 | if (context.Request.QueryString.HasValue)
43 | path += context.Request.QueryString.Value;
44 |
45 | return new()
46 | {
47 | Request = new()
48 | {
49 | Path = path,
50 | Method = context.Request.Method.ToString(),
51 | RawBody = await new StreamContent(context.Request.Body).ReadAsStringAsync()
52 | }
53 | };
54 | }
55 | private HttpRequestMessage InitiateOutboundRequest(Transaction trans)
56 | {
57 | var outboundRequest = new HttpRequestMessage();
58 | trans.Request.AddHeaders(outboundRequest);
59 | outboundRequest.RequestUri = new Uri(_contract.URL + trans.Request.Path);
60 | outboundRequest.Method = new HttpMethod(trans.Request.Method);
61 | return outboundRequest;
62 | }
63 | private void CopyHeadersAndContentFromRequest(HttpContext context, HttpRequestMessage requestMessage, Transaction trans)
64 | {
65 | var requestMethod = context.Request.Method;
66 |
67 | if (HttpMethods.IsPost(requestMethod) ||
68 | HttpMethods.IsPut(requestMethod) ||
69 | HttpMethods.IsPatch(requestMethod))
70 | {
71 | context.Request.Body.Position = 0;
72 | var streamContent = new StreamContent(context.Request.Body);
73 | requestMessage.Content = streamContent;
74 | }
75 |
76 | foreach (var header in context.Request.Headers)
77 | {
78 |
79 | if (header.Key.ToLower() == "host")
80 | continue;
81 |
82 | if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()))
83 | {
84 | requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
85 | }
86 | if (header.Key == "Authorization") // we are not recording bearer tokens, we pass them in when verifying..
87 | continue;
88 |
89 | trans.Request.Headers.Add(header.Key, header.Value);
90 |
91 | }
92 | // finally set our target host
93 | requestMessage.Headers.Host = new Uri(_contract.URL + trans.Request.Path).Host;
94 | }
95 |
96 | private async Task ReadResponse(Transaction trans, HttpResponseMessage responseMessage)
97 | {
98 | var body = await responseMessage.Content.ReadAsStringAsync();
99 | trans.Response = new Response() { StatusCode = (int)responseMessage.StatusCode, RawBody = body };
100 | trans.Response.AddHeaders(responseMessage);
101 | }
102 |
103 | private async Task SaveTransaction(Transaction trans)
104 | {
105 | _contract.Transactions.Add(trans);
106 | string jsonString = JsonSerializer.Serialize(_contract, new JsonSerializerOptions() { WriteIndented = true });
107 | var filePath = $"./contracts/{_contract.Name}.rumpel.contract.json";
108 | var file = new System.IO.FileInfo(filePath);
109 | file.Directory.Create(); // if it doesnt exist.. else this will be a NoOp
110 | await File.WriteAllTextAsync(filePath, jsonString, Encoding.UTF8);
111 | Printer.PrintInfo($"saved transaction for request {trans.Request.Method} to {trans.Request.Path}");
112 |
113 | }
114 | private async Task CopyHeadersAndContentFromResponse(HttpContext context, HttpResponseMessage responseMessage)
115 | {
116 | context.Response.StatusCode = (int)responseMessage.StatusCode;
117 | foreach (var header in responseMessage.Headers)
118 | {
119 | context.Response.Headers[header.Key] = header.Value.ToArray();
120 | }
121 |
122 | foreach (var header in responseMessage.Content.Headers)
123 | {
124 | context.Response.Headers[header.Key] = header.Value.ToArray();
125 | }
126 | // removing hop-by-hop-headers https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#hbh
127 | context.Response.Headers.Remove("transfer-encoding");
128 |
129 |
130 | await responseMessage.Content.CopyToAsync(context.Response.Body);
131 | }
132 |
133 |
134 | }
135 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Rumpel
2 | ## Simple, opinionated and automated consumer-driven contract testing for your JSON API's
3 |
4 |
5 | ## Install
6 |
7 | copy+paste one of the following commands in your terminal.
8 |
9 | **Linux x64**
10 | ```
11 | curl -Lo rumpel https://github.com/hellgrenj/Rumpel/releases/download/v0.4.2/rumpel-linux-x64 && \
12 | sudo install rumpel /usr/local/bin/
13 | ```
14 |
15 | **macOS arm64**
16 | ```
17 | curl -Lo rumpel https://github.com/hellgrenj/Rumpel/releases/download/v0.4.2/rumpel-macos-arm64 && \
18 | sudo install rumpel /usr/local/bin/
19 | ```
20 | **win x64**
21 | download the [latest exe](https://github.com/hellgrenj/Rumpel/releases/download/v0.4.2/rumpel-win-x64.exe), rename it ``rumpel.exe`` and put it in your PATH.
22 |
23 | **Docker**:
24 | https://hub.docker.com/r/hellgrenj/rumpel.
25 |
26 | **Helm (Experimental!)**:
27 | https://github.com/hellgrenj/charts/tree/main/rumpel-mock#install-and-test
28 | (only for mocking services right now..)
29 |
30 | ## Use
31 |
32 |
33 | Record a consumer-driven contract against a known and reproducible state of the API and system under test (SUT). Use the created contract to verify that the API still works for this specific consumer when making changes to the API. Make sure you verify against the same SUT-state as you recorded against.
34 |
35 | You can also use the contract on the consumer side to mock the provider in local development. In this mode Rumpel will validate the consumer requests, making sure that the consumer upholds its end of the contract.
36 |
37 |
38 | ### tldr
39 | ``rumpel --record-contract --target-api=http://localhost:8080 --contract-name=msA-msB``
40 | ``rumpel --verify-contract --contract-path=./contracts/msA-msB.rumpel.contract.json``
41 | ``rumpel --mock-provider --contract-path=./contracts/msA-msB.rumpel.contract.json``
42 |
43 | ### examples
44 | Demo application: [artlab](https://github.com/hellgrenj/artlab)
45 | *a demo of Docker, Kubernetes and Skaffold where Rumpel is used to record a contract and mock a dependency.*
46 |
47 | ### Rumpel can do **3** things:
48 | 1. **Record a contract** (i.e turning your implicit contract into an explicit one).
49 | ``rumpel --record-contract --target-api=http://localhost:8080 --contract-name=msA-msB``
50 | 
51 | Rumpel listens on port 8181 or the number set in the environment variable **RUMPEL_PORT**
52 | **screenshot**
53 | 
54 |
55 | 2. **Verify a contract** (i.e making sure the API still works for a specific consumer)
56 | ``rumpel --verify-contract --contract-path=./contracts/msA-msB.rumpel.contract.json``
57 | 
58 | Contract verification supports bearer tokens and you can skip certain assertions with **ignore-flags**.
59 | Run the --help command for more information.
60 | This should be a part of the Providers CI/CD pipeline, see ./tests/integration for an example on how to do this with docker-compose.
61 | **screenshots**
62 | 
63 | 
64 | **Customizations**
65 | You can customize the verification per transaction by manually adding Customizations in the contract. In the example below a Customization is added that instructs Rumpel to ignore the object property *name*. A Customization has 3 properties: The name of the target *Object Property*, The name of the *Action* and at what Depth in the JSON the property is found. (is it a first level property or a property in a nested object..)
66 | 
67 | **Available Customizations**
68 | - CompareObjectPropertyValues *(have Rumpel assert that the value is the same as the recorded value)*
69 | - IgnoreObjectProperty *(ignores one specific property in the object, i.e allow it to be missing or changed)*
70 |
71 |
72 | 3. **Mock a provider/API**
73 | ``rumpel --mock-provider --contract-path=./contracts/msA-msB.rumpel.contract.json``
74 | 
75 | This can be used in local dev environments to mock dependencies, see ./tests/integration for an example of how to do this with docker-compose or the repository [artlab](https://github.com/hellgrenj/artlab) for an example of how to do this with Skaffold. You can also mock services in different k8s-clusters with this (experimental) helm chart: https://github.com/hellgrenj/charts/tree/main/rumpel-mock#install-and-test
76 | Rumpel listens on port 8181 or the number set in the environment variable **RUMPEL_PORT**.
77 | In this mode Rumpel validates the requests sent by the consumer.
78 | **screenshot**
79 | 
80 | **Simulated conditions**
81 | You can simulate conditions per transaction when mocking a provider by manually adding SimulatedConditions in the contract. Every simulated condition has a *Type* and a *Value*, see the screenshot below for an example. You can add one or several simulated conditions, in the example below we are adding 3 different conditions.
82 | 
83 | **Available Types of Simulated conditions and the expected Values**
84 | * *Sometimes500* (Rumpel will have this request fail with status code 500 according to the percentage set as the Value. In the example (screenshot) above the request will fail approximately 35% of the time)
85 | * *FixedDelay* (Rumpel will add a delay to this request. The Value is the fixed delay in milliseconds)
86 | * *RandomDelay* (Rumpel will add a random delay between the min and max value provided in the Value property. The min and max values are expected to be provided with the following syntax: minMilliseconds-maxMilliseconds)
87 | ### Rumpel has 5 commands:
88 |
89 | ``--record-contract`` or the shorthand ``-r``
90 | ``--verify-contract`` or the shorthand ``-v``
91 | ``--mock-provider`` or the shorthand ``-m``
92 | ``--help`` or the shorthand ``-h``
93 | ``--version``
94 |
95 | ## Develop
96 |
97 | You need the following installed on your dev machine:
98 | * dotnet 6.x
99 | * docker 20.10.x
100 | * docker-compose 1.29.x
101 | * deno 1.15.x
102 |
103 | ### tests
104 | There are both unit tests and integration tests. Check the ./tests folder.
105 | Run unit tests in ./tests/unit with ``dotnet test``
106 | Run integration tests in ./tests/integration with ``deno run -A tests.ts``
107 | Run ALL tests in ./tests with ``deno run -A run_all_tests.ts``
108 |
109 |
110 |
111 |
--------------------------------------------------------------------------------
/src/Mocker.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Linq;
5 | using System.Net.Http;
6 | using System.Text;
7 | using System.Text.Json;
8 | using System.Threading;
9 | using System.Threading.Tasks;
10 | using Microsoft.AspNetCore.Http;
11 | using Rumpel.Models;
12 |
13 | public class Mocker
14 | {
15 | private Contract _contract;
16 | private static Random random = new();
17 | public Mocker(Contract contract) => _contract = contract;
18 |
19 | public async Task Run()
20 | {
21 | Printer.PrintInfo($"mocking provider for contract {_contract.Name}");
22 | await Server.Start(MockRequestHandler);
23 | }
24 |
25 | async Task MockRequestHandler(HttpContext context)
26 | {
27 | var method = context.Request.Method.ToString();
28 | var path = context.Request.Path.ToString();
29 | if (context.Request.QueryString.HasValue)
30 | path += context.Request.QueryString.Value;
31 |
32 | var trans = _contract.Transactions.Find(t => t.Request.Method == method && t.Request.Path == path);
33 | if (trans == null)
34 | {
35 | Printer.PrintInfo($"no response found for {method} {path}");
36 | context.Response.StatusCode = 404;
37 | await context.Response.CompleteAsync();
38 | }
39 | else
40 | {
41 | context.Response.StatusCode = GetStatusCode(trans);
42 | if (context.Response.StatusCode == 500)
43 | await Respond(context, String.Empty, trans.SimulatedConditions);
44 |
45 | AddHeaders(context, trans);
46 | if (HttpMethods.IsPost(trans.Request.Method) ||
47 | HttpMethods.IsPut(trans.Request.Method) ||
48 | HttpMethods.IsPatch(trans.Request.Method))
49 | {
50 | var (requestBodyOk, requestBodyErrors) = await ValidateRequestBody(context, trans);
51 | if (!requestBodyOk)
52 | {
53 | context.Response.StatusCode = 400;
54 | Printer.PrintInfo($"returning bad request with validation errors for {trans.Request.Method} {trans.Request.Path}");
55 | await Respond(context, JsonSerializer.Serialize(requestBodyErrors), trans.SimulatedConditions);
56 | return;
57 | }
58 | }
59 | Printer.PrintInfo($"returning pre-recorded response for {trans.Request.Method} {trans.Request.Path}");
60 | await Respond(context, trans.Response.RawBody, trans.SimulatedConditions);
61 | }
62 |
63 | }
64 | private int GetStatusCode(Transaction trans)
65 | {
66 | var defaultStatusCode = trans.Response.StatusCode;
67 | if (trans.SimulatedConditions is null)
68 | return defaultStatusCode;
69 |
70 | var sometimes500 = trans.SimulatedConditions.Find(sc => sc.Type == SimulatedConditionTypes.Sometimes500);
71 | if (sometimes500 is not null)
72 | {
73 | try
74 | {
75 | var percentage = Int32.Parse(sometimes500.Value);
76 | Printer.PrintInfo($"{percentage}% chance the recorded status code will be replaced with 500");
77 | var randomNumber = random.Next(100); // between 0-99
78 | if (randomNumber < percentage)
79 | {
80 | Printer.PrintInfo("simulating a 500");
81 | return 500;
82 | }
83 | }
84 | catch
85 | {
86 | Printer.PrintErr("could not parse percentage from Value for Sometimes500");
87 | }
88 | }
89 | return defaultStatusCode;
90 |
91 | }
92 | private void AddHeaders(HttpContext context, Transaction trans)
93 | {
94 | foreach (var header in trans.Response.Headers)
95 | {
96 | context.Response.Headers[header.Key] = header.Value.ToArray();
97 | }
98 | context.Response.Headers.Remove("transfer-encoding");
99 | }
100 | private async Task<(bool, List)> ValidateRequestBody(HttpContext context, Transaction trans)
101 | {
102 | var isValid = true;
103 | var errorMessages = new List();
104 |
105 | context.Request.Body.Position = 0;
106 | var streamContent = new StreamContent(context.Request.Body);
107 | var requestBodyAsString = await streamContent.ReadAsStringAsync();
108 | var (requestOk, requestErrors) = Interpreter.InferSchemaAndValidate(requestBodyAsString, trans.Request.RawBody, new List() { IgnoreFlags.IgnoreAssertArrayLength }, new());
109 | if (!requestOk)
110 | {
111 | isValid = false;
112 | errorMessages.AddRange(requestErrors);
113 | }
114 |
115 | return (isValid, errorMessages);
116 | }
117 | private async Task Respond(HttpContext context, string responseString, List simulatedConditions)
118 | {
119 | RunAnySimulatedDelays(simulatedConditions);
120 | var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(responseString));
121 | await memoryStream.CopyToAsync(context.Response.Body);
122 | await context.Response.CompleteAsync();
123 | }
124 | private void RunAnySimulatedDelays(List simulatedConditions)
125 | {
126 | if (simulatedConditions is null)
127 | return;
128 |
129 | var fixedDelay = simulatedConditions.Find(sc => sc.Type == SimulatedConditionTypes.FixedDelay);
130 | if (fixedDelay is not null)
131 | TrySimulateFixedDelay(fixedDelay);
132 |
133 | var randomDelay = simulatedConditions.Find(sc => sc.Type == SimulatedConditionTypes.RandomDelay);
134 | if (randomDelay is not null)
135 | TrySimulateRandomDelay(randomDelay);
136 | }
137 | private void TrySimulateFixedDelay(SimulatedCondition fixedDelay)
138 | {
139 | try
140 | {
141 | var delayInMilliseconds = Int32.Parse(fixedDelay.Value);
142 | Printer.PrintInfo($"simulating a fixed delay of {delayInMilliseconds} milliseconds");
143 | Thread.Sleep(delayInMilliseconds);
144 | }
145 | catch
146 | {
147 | Printer.PrintErr($"could not parse delay in Value for FixedDelay");
148 | }
149 | }
150 | private void TrySimulateRandomDelay(SimulatedCondition randomDelay)
151 | {
152 | try
153 | {
154 | var split = randomDelay.Value.Split("-");
155 | var minDelay = Int32.Parse(split[0]);
156 | var maxDelay = Int32.Parse(split[1]);
157 | var delayInMilliseconds = random.Next(minDelay, maxDelay);
158 | Printer.PrintInfo($"simulating a random delay of {delayInMilliseconds} milliseconds");
159 | Thread.Sleep(delayInMilliseconds);
160 | }
161 | catch
162 | {
163 | Printer.PrintErr($"could not parse delay min-max range in Value for RandomDelay. Expecting min-max in milliseconds, e.g 100-2000");
164 | }
165 | }
166 | }
--------------------------------------------------------------------------------
/tests/integration/tests.ts:
--------------------------------------------------------------------------------
1 | import { waitForEndpoint } from "./util.ts";
2 | import * as Colors from "https://deno.land/std@0.95.0/fmt/colors.ts";
3 |
4 | console.log("trying to cleanup prev run");
5 | const stoppingRunningAPI = Deno.run({
6 | cmd: ["docker-compose", "--file", "docker-compose-api.yaml", "down"],
7 | stdout: "piped",
8 | stdin: "piped",
9 | stderr: "piped",
10 | });
11 | await stoppingRunningAPI.status();
12 |
13 | const cleaningUpPrevRecordScenario = Deno.run({
14 | cmd: ["docker-compose", "--file", "docker-compose-record.yaml", "down"],
15 | stdout: "piped",
16 | stdin: "piped",
17 | stderr: "piped",
18 | });
19 | await cleaningUpPrevRecordScenario.status();
20 | const cleaningUpPrevVerifyScenario = Deno.run({
21 | cmd: ["docker-compose", "--file", "docker-compose-verify.yaml", "down"],
22 | stdout: "piped",
23 | stdin: "piped",
24 | stderr: "piped",
25 | });
26 | await cleaningUpPrevVerifyScenario.status();
27 | const cleaningUpPrevMockScenario = Deno.run({
28 | cmd: ["docker-compose", "--file", "docker-compose-mock.yaml", "down"],
29 | stdout: "piped",
30 | stdin: "piped",
31 | stderr: "piped",
32 | });
33 | await cleaningUpPrevMockScenario.status();
34 | console.log("cleanup done, moving on..");
35 |
36 | console.log(Colors.blue("starting test API"));
37 |
38 | Deno.run({
39 | cmd: [
40 | "docker-compose",
41 | "--file",
42 | "docker-compose-api.yaml",
43 | "up",
44 | "--build",
45 | ],
46 | stdout: "piped",
47 | stdin: "piped",
48 | stderr: "piped",
49 | });
50 |
51 | console.log("waiting for test-api to be available..");
52 | const apiHealthEndpoint = "http://localhost:1337/health";
53 | await waitForEndpoint(apiHealthEndpoint);
54 |
55 | const jwt = await fetch("http://localhost:1337/token", { method: "GET" }).then(
56 | async (response) => await response.text(),
57 | );
58 |
59 | console.log(Colors.blue("starting recording scenario"));
60 |
61 | const recordScenario = Deno.run({
62 | cmd: [
63 | "docker-compose",
64 | "--file",
65 | "docker-compose-record.yaml",
66 | "up",
67 | "--build",
68 | ],
69 | stdout: "piped",
70 | stdin: "piped",
71 | stderr: "piped",
72 | });
73 |
74 | console.log("waiting for test-api to be available..");
75 | const apiHealthEndpointThruRumpel = "http://localhost:8585/health";
76 | await waitForEndpoint(apiHealthEndpointThruRumpel);
77 |
78 | const consumerSim = Deno.run({
79 | env: {
80 | "BEARER_TOKEN": jwt,
81 | },
82 | cmd: [
83 | "deno",
84 | "run",
85 | "-A",
86 | "consumer.ts",
87 | ],
88 | });
89 | await consumerSim.status();
90 |
91 | recordScenario.close();
92 | console.log(Colors.blue("stopping recording scenario.."));
93 | const stoppingRecordScenario = Deno.run({
94 | cmd: ["docker-compose", "--file", "docker-compose-record.yaml", "down"],
95 | stdout: "piped",
96 | stdin: "piped",
97 | stderr: "piped",
98 | });
99 | await stoppingRecordScenario.status();
100 |
101 | console.log(
102 | Colors.yellow(
103 | "\napplying customizations to request GET /cakes/1 and simulate a change in the API\n",
104 | ),
105 | );
106 | const contract = JSON.parse(
107 | Deno.readTextFileSync("./contracts/consumer-api.rumpel.contract.json").trim(),
108 | );
109 | contract.Transactions.filter((t: any) =>
110 | t.Request.Path == "/cakes/1" && t.Request.Method == "GET"
111 | ).forEach((t: any) => {
112 | // copy so original is left for the mocker test below...
113 | contract.Transactions.splice(
114 | contract.Transactions.indexOf(t),
115 | 0,
116 | JSON.parse(JSON.stringify(t)),
117 | );
118 | // create a V2 with customizations.. (response still expects property name, but IgnoreObjectProperty customization makes the verification pass anyway..)
119 | t.Request.Path = "/V2/cakes/1";
120 | t.Customizations.push({
121 | PropertyName: "name",
122 | Action: "IgnoreObjectProperty",
123 | Depth: 0,
124 | });
125 | });
126 | Deno.writeTextFileSync(
127 | "./contracts/consumer-api.rumpel.contract.json",
128 | JSON.stringify(contract, null, 2),
129 | );
130 |
131 | console.log(Colors.blue("starting verification scenario.."));
132 | const verificationScenario = Deno.run({
133 | env: {
134 | "BEARER_TOKEN": jwt,
135 | },
136 | cmd: [
137 | "docker-compose",
138 | "--file",
139 | "docker-compose-verify.yaml",
140 | "up",
141 | "--build",
142 | ],
143 | stdout: "piped",
144 | stdin: "piped",
145 | stderr: "piped",
146 | });
147 |
148 | console.log("waiting for Rumpel to be done..");
149 | const verificationResult = new TextDecoder().decode(
150 | await verificationScenario.output(),
151 | );
152 | console.log("Rumpel verification is done!");
153 |
154 | const verificationSucceeded = verificationResult.includes(
155 | "CONTRACT TEST PASSED! (CONTRACT: CONSUMER-API)",
156 | );
157 | if (verificationSucceeded) {
158 | console.log(Colors.green("verification succeeded"));
159 | }
160 | console.log(Colors.blue("stopping verification scenario.."));
161 | const stoppingVerificationScenario = Deno.run({
162 | cmd: ["docker-compose", "--file", "docker-compose-verify.yaml", "down"],
163 | stdout: "piped",
164 | stdin: "piped",
165 | stderr: "piped",
166 | });
167 | await stoppingVerificationScenario.status();
168 |
169 | console.log(
170 | Colors.yellow(
171 | "\napplying simulated conditions to request GET /cakes\n",
172 | ),
173 | );
174 | contract.Transactions.filter((t: any) =>
175 | t.Request.Path == "/cakes" && t.Request.Method == "GET"
176 | ).forEach((t: any) => {
177 | t.SimulatedConditions.push({
178 | Type: "Sometimes500",
179 | Value: "35",
180 | });
181 | t.SimulatedConditions.push({
182 | Type: "FixedDelay",
183 | Value: "500",
184 | });
185 | t.SimulatedConditions.push({
186 | Type: "RandomDelay",
187 | Value: "100-4000",
188 | });
189 | });
190 | Deno.writeTextFileSync(
191 | "./contracts/consumer-api.rumpel.contract.json",
192 | JSON.stringify(contract, null, 2),
193 | );
194 |
195 | console.log(Colors.blue("starting mocking scenario.."));
196 | const mockProviderScenario = Deno.run({
197 | cmd: [
198 | "docker-compose",
199 | "--file",
200 | "docker-compose-mock.yaml",
201 | "up",
202 | "--build",
203 | ],
204 | stdin: "piped",
205 | stderr: "piped",
206 | });
207 | const mockedHealthEndpoint = "http://localhost:8585/health";
208 | await waitForEndpoint(mockedHealthEndpoint);
209 |
210 | const consumerAgainstMockServerSim = Deno.run({
211 | cmd: [
212 | "deno",
213 | "run",
214 | "-A",
215 | "consumer.ts",
216 | ],
217 | });
218 | const mockingSucceeded = await consumerAgainstMockServerSim.status().then((r) =>
219 | r.success
220 | );
221 | if (mockingSucceeded) {
222 | console.log(Colors.green("mocking succeeded"));
223 | }
224 | mockProviderScenario.close();
225 | console.log(Colors.blue("stopping mocking scenario.."));
226 | const stoppingMockScenario = Deno.run({
227 | cmd: ["docker-compose", "--file", "docker-compose-mock.yaml", "down"],
228 | stdout: "piped",
229 | stdin: "piped",
230 | stderr: "piped",
231 | });
232 | await stoppingMockScenario.status();
233 |
234 | const stopApiProcess = Deno.run({
235 | cmd: ["docker-compose", "--file", "docker-compose-api.yaml", "down"],
236 | stdout: "piped",
237 | stdin: "piped",
238 | stderr: "piped",
239 | });
240 | await stopApiProcess.status();
241 |
242 | if (verificationSucceeded && mockingSucceeded) {
243 | console.log(
244 | Colors.bold(Colors.green("integration tests passed!".toUpperCase())),
245 | );
246 | } else {
247 | console.log(
248 | Colors.bold(Colors.red("integration tests failed!".toUpperCase())),
249 | );
250 | console.log(verificationResult);
251 | Deno.exit(1);
252 | }
253 |
--------------------------------------------------------------------------------
/src/Interpreter.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text.Json;
4 | using Rumpel.Models;
5 |
6 | public static class Interpreter
7 | {
8 | public static (bool, List) InferSchemaAndValidate(string jsonString, string expectedJsonString,
9 | List ignoreFlags, List customizations)
10 | {
11 | var isValid = true;
12 | var errorMessages = new List();
13 |
14 | var emptyJsonExpectedAndReceived = String.IsNullOrEmpty(jsonString) && String.IsNullOrEmpty(expectedJsonString);
15 | if (emptyJsonExpectedAndReceived)
16 | {
17 | return (isValid, errorMessages);
18 | }
19 | var json = JsonSerializer.Deserialize(jsonString);
20 | var expectedJson = JsonSerializer.Deserialize(expectedJsonString);
21 | switch (expectedJson.ValueKind)
22 | {
23 | case JsonValueKind.Object:
24 | var (objectOk, objectPropertiesErrors) = AssertObjectProperties(expectedJson.GetRawText(), json.GetRawText(),
25 | ignoreFlags, customizations);
26 | isValid = objectOk ? isValid : false;
27 | errorMessages.AddRange(objectPropertiesErrors);
28 | break;
29 | case JsonValueKind.Array:
30 | var (arrayOk, arrayErrors) = AssertArray(expectedJson, json, ignoreFlags, customizations);
31 | isValid = arrayOk ? isValid : false;
32 | errorMessages.AddRange(arrayErrors);
33 | break;
34 | default:
35 | var (singleValueOk, singleValueErrors) = AssertSingleValue(expectedJson, json);
36 | isValid = singleValueOk ? isValid : false;
37 | errorMessages.AddRange(singleValueErrors);
38 | break;
39 | }
40 | return (isValid, errorMessages);
41 | }
42 |
43 | private static (bool, List) AssertArray(JsonElement expectedJson, JsonElement json, List ignoreFlags,
44 | List customizations, int nestedDepth = 0, string nestedInParentType = null)
45 | {
46 | var isValid = true;
47 | var errorMessages = new List();
48 | var (arrayLengthOk, arrayLengthErrors) = AssertJsonArrayLength(expectedJson, json, ignoreFlags);
49 | isValid = arrayLengthOk ? isValid : false;
50 | errorMessages.AddRange(arrayLengthErrors);
51 |
52 | for (var i = 0; i < expectedJson.GetArrayLength(); i++)
53 | {
54 | if (json.GetArrayLength() < (i + 1))
55 | continue;
56 |
57 | if (json[i].ValueKind == JsonValueKind.Array)
58 | {
59 | var nextLevel = nestedDepth + 1;
60 | var (nestedArrayOk, nestedArrayErrors) = AssertArray(expectedJson[i], json[i], ignoreFlags, customizations, nextLevel, "array");
61 | isValid = nestedArrayOk ? isValid : false;
62 | errorMessages.AddRange(nestedArrayErrors);
63 | }
64 | else if (json[i].ValueKind == JsonValueKind.Object)
65 | {
66 | var nextLevel = nestedDepth + 1;
67 | var (objectPropertiesOk, objectPropertiesErrors) = AssertObjectProperties(expectedJson[i].GetRawText(), json[i].GetRawText(),
68 | ignoreFlags, customizations, nextLevel, "array");
69 | isValid = objectPropertiesOk ? isValid : false;
70 | errorMessages.AddRange(objectPropertiesErrors);
71 | }
72 | else
73 | {
74 | var (singleValueOk, singleValueErrors) = AssertSingleValue(expectedJson[i], json[i], nestedDepth, "array");
75 | isValid = singleValueOk ? isValid : false;
76 | errorMessages.AddRange(singleValueErrors);
77 | }
78 | }
79 | return (isValid, errorMessages);
80 | }
81 |
82 | private static (bool, List) AssertJsonArrayLength(JsonElement expectedJson, JsonElement json, List ignoreFlags)
83 | {
84 | var isValid = true;
85 | var errorMessages = new List();
86 | if (expectedJson.GetArrayLength() != json.GetArrayLength() && !ignoreFlags.Contains(IgnoreFlags.IgnoreAssertArrayLength))
87 | {
88 | isValid = false;
89 | errorMessages.Add($"Expected array to have length {expectedJson.GetArrayLength()} but it was {json.GetArrayLength()}");
90 | }
91 | return (isValid, errorMessages);
92 | }
93 |
94 | private static (bool, List) AssertObjectProperties(string expectedJsonString, string jsonString,
95 | List ignoreFlags, List customizations, int nestedDepth = 0, string nestedInParentType = null)
96 | {
97 | var isValid = true;
98 | var errorMessages = new List();
99 | var expectedJsonObj = JsonSerializer.Deserialize>(expectedJsonString);
100 | var jsonObj = JsonSerializer.Deserialize>(jsonString);
101 | foreach (var key in expectedJsonObj.Keys)
102 | {
103 | if (jsonObj.ContainsKey(key) == false)
104 | {
105 | if (!CustomizedTo(CustomizationActions.IgnoreObjectProperty, customizations, key, nestedDepth))
106 | {
107 | isValid = false;
108 | var errorMessage = $"Object missing property {key} of type {expectedJsonObj[key].ValueKind}";
109 | errorMessage = AddNestedInfoIfNested(nestedDepth, nestedInParentType, errorMessage);
110 | errorMessages.Add(errorMessage);
111 | }
112 | }
113 | else if (jsonObj[key].ValueKind == JsonValueKind.Object)
114 | {
115 | var nextLevel = nestedDepth + 1;
116 | var (nestedObjectPropertiesOk, nestedObjectPropertiesErrors) = AssertObjectProperties(expectedJsonObj[key].GetRawText(),
117 | jsonObj[key].GetRawText(),
118 | ignoreFlags, customizations, nextLevel, "object");
119 | isValid = nestedObjectPropertiesOk ? isValid : false;
120 | errorMessages.AddRange(nestedObjectPropertiesErrors);
121 | }
122 | else if (jsonObj[key].ValueKind == JsonValueKind.Array)
123 | {
124 | var nextLevel = nestedDepth + 1;
125 | var (arrayOk, arrayErrors) = AssertArray(expectedJsonObj[key], jsonObj[key], ignoreFlags, customizations, nextLevel, "object");
126 | isValid = arrayOk ? isValid : false;
127 | errorMessages.AddRange(arrayErrors);
128 | }
129 | else if (jsonObj[key].ValueKind != expectedJsonObj[key].ValueKind)
130 | {
131 | isValid = false;
132 | var errorMessage = $"property with name {key} is {jsonObj[key].ValueKind} and expected type is {expectedJsonObj[key].ValueKind}";
133 | errorMessage = AddNestedInfoIfNested(nestedDepth, nestedInParentType, errorMessage);
134 | errorMessages.Add(errorMessage);
135 | }
136 | else if (jsonObj[key].ValueKind == expectedJsonObj[key].ValueKind
137 | && CustomizedTo(CustomizationActions.CompareObjectPropertyValues, customizations, key, nestedDepth))
138 | {
139 | var (propertyValueOk, propertyValueErrors) = AssertPropertyValue(key, expectedJsonObj, jsonObj, nestedDepth, nestedInParentType);
140 | isValid = propertyValueOk ? isValid : false;
141 | errorMessages.AddRange(propertyValueErrors);
142 | }
143 | }
144 | return (isValid, errorMessages);
145 | }
146 | private static (bool, List) AssertSingleValue(JsonElement expectedJson, JsonElement json, int nestedDepth = 0,
147 | string nestedInParentType = null)
148 | {
149 | var isValid = true;
150 | var errorMessages = new List();
151 | if (json.ValueKind != expectedJson.ValueKind)
152 | {
153 | isValid = false;
154 | var errorMessage = $"expected single value of type {expectedJson.ValueKind} but it was {json.ValueKind}";
155 | errorMessage = AddNestedInfoIfNested(nestedDepth, nestedInParentType, errorMessage);
156 | errorMessages.Add(errorMessage);
157 | }
158 | return (isValid, errorMessages);
159 | }
160 | private static (bool, List) AssertPropertyValue(string key, Dictionary expectedJsonObj,
161 | Dictionary jsonObj, int nestedDepth = 0, string nestedInParentType = null)
162 | {
163 | var isValid = true;
164 | var errorMessages = new List();
165 | if (jsonObj[key].GetRawText() != expectedJsonObj[key].GetRawText())
166 | {
167 | isValid = false;
168 | var errorMessage = $"property with name {key} has the value {jsonObj[key].GetRawText()} and the expected value is {expectedJsonObj[key].GetRawText()}";
169 | errorMessage = AddNestedInfoIfNested(nestedDepth, nestedInParentType, errorMessage);
170 | errorMessages.Add(errorMessage);
171 | }
172 | return (isValid, errorMessages);
173 | }
174 | private static string AddNestedInfoIfNested(int nestedDepth, string nestedInParentType, string errorMessage)
175 | {
176 | if (nestedDepth > 0 && nestedInParentType != null)
177 | {
178 | errorMessage += $" in a nested {nestedInParentType} (depth {nestedDepth})";
179 | }
180 | return errorMessage;
181 | }
182 | private static bool CustomizedTo(string action, List customizations, string propName, int nestedDepth)
183 | {
184 | return customizations.Exists(c => c.PropertyName == propName && c.Action == action && c.Depth == nestedDepth);
185 | }
186 |
187 |
188 | }
189 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # project custom
2 | .vscode
3 |
4 | *.rumpel.contract.json
5 | ## Ignore Visual Studio temporary files, build results, and
6 | ## files generated by popular Visual Studio add-ons.
7 | ##
8 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
9 |
10 | # User-specific files
11 | *.rsuser
12 | *.suo
13 | *.user
14 | *.userosscache
15 | *.sln.docstates
16 |
17 | # User-specific files (MonoDevelop/Xamarin Studio)
18 | *.userprefs
19 |
20 | # Mono auto generated files
21 | mono_crash.*
22 |
23 | # Build results
24 | [Dd]ebug/
25 | [Dd]ebugPublic/
26 | [Rr]elease/
27 | [Rr]eleases/
28 | x64/
29 | x86/
30 | [Ww][Ii][Nn]32/
31 | [Aa][Rr][Mm]/
32 | [Aa][Rr][Mm]64/
33 | bld/
34 | [Bb]in/
35 | [Oo]bj/
36 | [Ll]og/
37 | [Ll]ogs/
38 |
39 | # Visual Studio 2015/2017 cache/options directory
40 | .vs/
41 | # Uncomment if you have tasks that create the project's static files in wwwroot
42 | #wwwroot/
43 |
44 | # Visual Studio 2017 auto generated files
45 | Generated\ Files/
46 |
47 | # MSTest test Results
48 | [Tt]est[Rr]esult*/
49 | [Bb]uild[Ll]og.*
50 |
51 | # NUnit
52 | *.VisualState.xml
53 | TestResult.xml
54 | nunit-*.xml
55 |
56 | # Build Results of an ATL Project
57 | [Dd]ebugPS/
58 | [Rr]eleasePS/
59 | dlldata.c
60 |
61 | # Benchmark Results
62 | BenchmarkDotNet.Artifacts/
63 |
64 | # .NET Core
65 | project.lock.json
66 | project.fragment.lock.json
67 | artifacts/
68 |
69 | # Tye
70 | .tye/
71 |
72 | # ASP.NET Scaffolding
73 | ScaffoldingReadMe.txt
74 |
75 | # StyleCop
76 | StyleCopReport.xml
77 |
78 | # Files built by Visual Studio
79 | *_i.c
80 | *_p.c
81 | *_h.h
82 | *.ilk
83 | *.meta
84 | *.obj
85 | *.iobj
86 | *.pch
87 | *.pdb
88 | *.ipdb
89 | *.pgc
90 | *.pgd
91 | *.rsp
92 | *.sbr
93 | *.tlb
94 | *.tli
95 | *.tlh
96 | *.tmp
97 | *.tmp_proj
98 | *_wpftmp.csproj
99 | *.log
100 | *.vspscc
101 | *.vssscc
102 | .builds
103 | *.pidb
104 | *.svclog
105 | *.scc
106 |
107 | # Chutzpah Test files
108 | _Chutzpah*
109 |
110 | # Visual C++ cache files
111 | ipch/
112 | *.aps
113 | *.ncb
114 | *.opendb
115 | *.opensdf
116 | *.sdf
117 | *.cachefile
118 | *.VC.db
119 | *.VC.VC.opendb
120 |
121 | # Visual Studio profiler
122 | *.psess
123 | *.vsp
124 | *.vspx
125 | *.sap
126 |
127 | # Visual Studio Trace Files
128 | *.e2e
129 |
130 | # TFS 2012 Local Workspace
131 | $tf/
132 |
133 | # Guidance Automation Toolkit
134 | *.gpState
135 |
136 | # ReSharper is a .NET coding add-in
137 | _ReSharper*/
138 | *.[Rr]e[Ss]harper
139 | *.DotSettings.user
140 |
141 | # TeamCity is a build add-in
142 | _TeamCity*
143 |
144 | # DotCover is a Code Coverage Tool
145 | *.dotCover
146 |
147 | # AxoCover is a Code Coverage Tool
148 | .axoCover/*
149 | !.axoCover/settings.json
150 |
151 | # Coverlet is a free, cross platform Code Coverage Tool
152 | coverage*.json
153 | coverage*.xml
154 | coverage*.info
155 |
156 | # Visual Studio code coverage results
157 | *.coverage
158 | *.coveragexml
159 |
160 | # NCrunch
161 | _NCrunch_*
162 | .*crunch*.local.xml
163 | nCrunchTemp_*
164 |
165 | # MightyMoose
166 | *.mm.*
167 | AutoTest.Net/
168 |
169 | # Web workbench (sass)
170 | .sass-cache/
171 |
172 | # Installshield output folder
173 | [Ee]xpress/
174 |
175 | # DocProject is a documentation generator add-in
176 | DocProject/buildhelp/
177 | DocProject/Help/*.HxT
178 | DocProject/Help/*.HxC
179 | DocProject/Help/*.hhc
180 | DocProject/Help/*.hhk
181 | DocProject/Help/*.hhp
182 | DocProject/Help/Html2
183 | DocProject/Help/html
184 |
185 | # Click-Once directory
186 | publish/
187 |
188 | # Publish Web Output
189 | *.[Pp]ublish.xml
190 | *.azurePubxml
191 | # Note: Comment the next line if you want to checkin your web deploy settings,
192 | # but database connection strings (with potential passwords) will be unencrypted
193 | *.pubxml
194 | *.publishproj
195 |
196 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
197 | # checkin your Azure Web App publish settings, but sensitive information contained
198 | # in these scripts will be unencrypted
199 | PublishScripts/
200 |
201 | # NuGet Packages
202 | *.nupkg
203 | # NuGet Symbol Packages
204 | *.snupkg
205 | # The packages folder can be ignored because of Package Restore
206 | **/[Pp]ackages/*
207 | # except build/, which is used as an MSBuild target.
208 | !**/[Pp]ackages/build/
209 | # Uncomment if necessary however generally it will be regenerated when needed
210 | #!**/[Pp]ackages/repositories.config
211 | # NuGet v3's project.json files produces more ignorable files
212 | *.nuget.props
213 | *.nuget.targets
214 |
215 | # Microsoft Azure Build Output
216 | csx/
217 | *.build.csdef
218 |
219 | # Microsoft Azure Emulator
220 | ecf/
221 | rcf/
222 |
223 | # Windows Store app package directories and files
224 | AppPackages/
225 | BundleArtifacts/
226 | Package.StoreAssociation.xml
227 | _pkginfo.txt
228 | *.appx
229 | *.appxbundle
230 | *.appxupload
231 |
232 | # Visual Studio cache files
233 | # files ending in .cache can be ignored
234 | *.[Cc]ache
235 | # but keep track of directories ending in .cache
236 | !?*.[Cc]ache/
237 |
238 | # Others
239 | ClientBin/
240 | ~$*
241 | *~
242 | *.dbmdl
243 | *.dbproj.schemaview
244 | *.jfm
245 | *.pfx
246 | *.publishsettings
247 | orleans.codegen.cs
248 |
249 | # Including strong name files can present a security risk
250 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
251 | #*.snk
252 |
253 | # Since there are multiple workflows, uncomment next line to ignore bower_components
254 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
255 | #bower_components/
256 |
257 | # RIA/Silverlight projects
258 | Generated_Code/
259 |
260 | # Backup & report files from converting an old project file
261 | # to a newer Visual Studio version. Backup files are not needed,
262 | # because we have git ;-)
263 | _UpgradeReport_Files/
264 | Backup*/
265 | UpgradeLog*.XML
266 | UpgradeLog*.htm
267 | ServiceFabricBackup/
268 | *.rptproj.bak
269 |
270 | # SQL Server files
271 | *.mdf
272 | *.ldf
273 | *.ndf
274 |
275 | # Business Intelligence projects
276 | *.rdl.data
277 | *.bim.layout
278 | *.bim_*.settings
279 | *.rptproj.rsuser
280 | *- [Bb]ackup.rdl
281 | *- [Bb]ackup ([0-9]).rdl
282 | *- [Bb]ackup ([0-9][0-9]).rdl
283 |
284 | # Microsoft Fakes
285 | FakesAssemblies/
286 |
287 | # GhostDoc plugin setting file
288 | *.GhostDoc.xml
289 |
290 | # Node.js Tools for Visual Studio
291 | .ntvs_analysis.dat
292 | node_modules/
293 |
294 | # Visual Studio 6 build log
295 | *.plg
296 |
297 | # Visual Studio 6 workspace options file
298 | *.opt
299 |
300 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
301 | *.vbw
302 |
303 | # Visual Studio LightSwitch build output
304 | **/*.HTMLClient/GeneratedArtifacts
305 | **/*.DesktopClient/GeneratedArtifacts
306 | **/*.DesktopClient/ModelManifest.xml
307 | **/*.Server/GeneratedArtifacts
308 | **/*.Server/ModelManifest.xml
309 | _Pvt_Extensions
310 |
311 | # Paket dependency manager
312 | .paket/paket.exe
313 | paket-files/
314 |
315 | # FAKE - F# Make
316 | .fake/
317 |
318 | # CodeRush personal settings
319 | .cr/personal
320 |
321 | # Python Tools for Visual Studio (PTVS)
322 | __pycache__/
323 | *.pyc
324 |
325 | # Cake - Uncomment if you are using it
326 | # tools/**
327 | # !tools/packages.config
328 |
329 | # Tabs Studio
330 | *.tss
331 |
332 | # Telerik's JustMock configuration file
333 | *.jmconfig
334 |
335 | # BizTalk build output
336 | *.btp.cs
337 | *.btm.cs
338 | *.odx.cs
339 | *.xsd.cs
340 |
341 | # OpenCover UI analysis results
342 | OpenCover/
343 |
344 | # Azure Stream Analytics local run output
345 | ASALocalRun/
346 |
347 | # MSBuild Binary and Structured Log
348 | *.binlog
349 |
350 | # NVidia Nsight GPU debugger configuration file
351 | *.nvuser
352 |
353 | # MFractors (Xamarin productivity tool) working folder
354 | .mfractor/
355 |
356 | # Local History for Visual Studio
357 | .localhistory/
358 |
359 | # BeatPulse healthcheck temp database
360 | healthchecksdb
361 |
362 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
363 | MigrationBackup/
364 |
365 | # Ionide (cross platform F# VS Code tools) working folder
366 | .ionide/
367 |
368 | # Fody - auto-generated XML schema
369 | FodyWeavers.xsd
370 |
371 | ##
372 | ## Visual studio for Mac
373 | ##
374 |
375 |
376 | # globs
377 | Makefile.in
378 | *.userprefs
379 | *.usertasks
380 | config.make
381 | config.status
382 | aclocal.m4
383 | install-sh
384 | autom4te.cache/
385 | *.tar.gz
386 | tarballs/
387 | test-results/
388 |
389 | # Mac bundle stuff
390 | *.dmg
391 | *.app
392 |
393 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
394 | # General
395 | .DS_Store
396 | .AppleDouble
397 | .LSOverride
398 |
399 | # Icon must end with two \r
400 | Icon
401 |
402 |
403 | # Thumbnails
404 | ._*
405 |
406 | # Files that might appear in the root of a volume
407 | .DocumentRevisions-V100
408 | .fseventsd
409 | .Spotlight-V100
410 | .TemporaryItems
411 | .Trashes
412 | .VolumeIcon.icns
413 | .com.apple.timemachine.donotpresent
414 |
415 | # Directories potentially created on remote AFP share
416 | .AppleDB
417 | .AppleDesktop
418 | Network Trash Folder
419 | Temporary Items
420 | .apdisk
421 |
422 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
423 | # Windows thumbnail cache files
424 | Thumbs.db
425 | ehthumbs.db
426 | ehthumbs_vista.db
427 |
428 | # Dump file
429 | *.stackdump
430 |
431 | # Folder config file
432 | [Dd]esktop.ini
433 |
434 | # Recycle Bin used on file shares
435 | $RECYCLE.BIN/
436 |
437 | # Windows Installer files
438 | *.cab
439 | *.msi
440 | *.msix
441 | *.msm
442 | *.msp
443 |
444 | # Windows shortcuts
445 | *.lnk
446 |
447 | # JetBrains Rider
448 | .idea/
449 | *.sln.iml
450 |
451 | ##
452 | ## Visual Studio Code
453 | ##
454 | .vscode/*
455 | !.vscode/settings.json
456 | !.vscode/tasks.json
457 | !.vscode/launch.json
458 | !.vscode/extensions.json
459 |
--------------------------------------------------------------------------------
/tests/unit/InterpreterTests.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections;
3 | using System.Collections.Generic;
4 | using System.Text.Json;
5 | using Rumpel.Models;
6 | using Xunit;
7 |
8 | namespace unit
9 | {
10 | public class InterpreterTests
11 | {
12 | [Fact]
13 | public void InferSchemaAndValidate_returns_true_and_empty_errors_if_json_ok()
14 | {
15 | var jsonString = @"
16 | {
17 | ""id"": 1,
18 | ""name"": ""test""
19 | }";
20 | var expectedJsonString = @"
21 | {
22 | ""id"": 37,
23 | ""name"": ""testarido...""
24 | }";
25 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
26 |
27 | Assert.True(isValid);
28 | Assert.Empty(errorMessages);
29 | }
30 |
31 |
32 | [Fact]
33 | public void InferSchemaAndValidate_validates_object_properties()
34 | {
35 | var jsonString = @"
36 | {
37 | ""id"": 1,
38 | ""name"": ""test""
39 | }";
40 | var expectedJsonString = @"
41 | {
42 | ""id"": 18,
43 | ""name"": ""does not matter""
44 | }";
45 |
46 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
47 |
48 | Assert.True(isValid);
49 | Assert.Empty(errorMessages);
50 | }
51 |
52 |
53 |
54 | [Fact]
55 | public void InferSchemaAndValidate_returns_false_and_errorMessages_if_wrong_property_type()
56 | {
57 | var jsonString = @"
58 | {
59 | ""id"": 1,
60 | ""name"": ""test""
61 | }";
62 | var expectedJsonString = @"
63 | {
64 | ""id"": ""expecting a string id"",
65 | ""name"": ""test""
66 | }";
67 |
68 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
69 |
70 | Assert.False(isValid);
71 | Assert.Contains("property with name id is Number and expected type is String", errorMessages[0]);
72 | }
73 |
74 | [Fact]
75 | public void InferSchemaAndValidate_returns_false_and_errorMessages_if_missing_property()
76 | {
77 | var jsonString = @"
78 | {
79 | ""id"": 1,
80 | ""name"": ""test""
81 | }";
82 | var expectedJsonString = @"
83 | {
84 | ""id"": 1,
85 | ""name"": ""test"",
86 | ""age"": 39
87 | }";
88 |
89 |
90 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
91 | Assert.False(isValid);
92 | Assert.Contains("Object missing property age", errorMessages[0]);
93 | }
94 |
95 | [Fact]
96 | public void InferSchemaAndValidate_passes_if_missing_property_but_customized_to_ignore_property()
97 | {
98 | var jsonString = @"
99 | {
100 | ""id"": 1,
101 | ""name"": ""test""
102 | }";
103 | var expectedJsonString = @"
104 | {
105 | ""id"": 1,
106 | ""name"": ""test"",
107 | ""age"": 39
108 | }";
109 |
110 |
111 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new()
112 | {
113 | new("age", 0, CustomizationActions.IgnoreObjectProperty)
114 | });
115 | Assert.True(isValid);
116 | Assert.Empty(errorMessages);
117 | }
118 | [Fact]
119 | public void InferSchemaAndValidate_returns_false_and_errorMessages_if_customized_to_compare_prop_values()
120 | {
121 | var jsonString = @"
122 | {
123 | ""id"": 1,
124 | ""nickname"": ""test""
125 | }";
126 | // testarido is not the same value as test for property nickname
127 | var expectedJsonString = @"
128 | {
129 | ""id"": 37,
130 | ""nickname"": ""testarido...""
131 | }";
132 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new()
133 | {
134 | new Customization("nickname", 0, CustomizationActions.CompareObjectPropertyValues)
135 | });
136 |
137 | Assert.False(isValid);
138 | Assert.Contains("property with name nickname has the value \"test\" and the expected value is \"testarido...\"", errorMessages[0]);
139 | }
140 |
141 |
142 | [Fact]
143 | public void InferSchemaAndValidate_returns_false_and_errorMessages_if_wrong_single_type()
144 | {
145 | var jsonString = "1";
146 | var expectedJsonString = @"""Expecting string""";
147 |
148 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
149 | Assert.False(isValid);
150 | Assert.Contains("expected single value of type String but it was Number", errorMessages[0]);
151 | }
152 |
153 | [Fact]
154 | public void InferSchemaAndValidate_returns_false_and_errorMessagess_if_wrong_singleValue_type_in_array()
155 | {
156 |
157 | var jsonString = @"[""test"",""test2"",""test3""]";
158 | var expectedJsonString = @"[1,2,3]";
159 |
160 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
161 |
162 | Assert.False(isValid);
163 | Assert.Contains("expected single value of type Number but it was String", errorMessages[0]);
164 | }
165 | [Fact]
166 | public void InferSchemaAndValidate_returns_false_and_errorMessages_If_unexpected_object_in_array()
167 | {
168 | var jsonString = @"[{""id"":1, ""name"":32},{""id"":2, ""name"":""maja""}]"; // name should be string not 32 as in first object in list..
169 | var expectedJsonString = @"[{""id"":1, ""name"":""should be string""},{""id"":2, ""name"":""maja""}]";
170 |
171 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
172 |
173 | Assert.False(isValid);
174 | Assert.Contains("property with name name is Number and expected type is String", errorMessages[0]);
175 | }
176 |
177 |
178 | [Fact]
179 | public void InferSchemaAndValidate_returns_false_and_errorMessagess_if_unexpected_array_length()
180 | {
181 |
182 | var jsonString = @"[""test"",""test2"",""test3""]";
183 | var expectedJsonString = @"[""test"",""test2""]"; // expected array length == 2
184 |
185 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
186 |
187 | Assert.False(isValid);
188 | Assert.Contains("Expected array to have length 2 but it was 3", errorMessages[0]);
189 | }
190 | [Fact]
191 | public void InferSchemaAndValidate_returns_true_if_unexpected_array_length_but_ignore_flag()
192 | {
193 |
194 | var jsonString = @"[""test"",""test2"",""test3""]";
195 | var expectedJsonString = @"[""test"",""test2""]"; // expected array length == 2
196 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List() { "--ignore-assert-array-length" }, new());
197 |
198 | Assert.True(isValid);
199 | Assert.Empty(errorMessages);
200 |
201 | }
202 |
203 | [Fact]
204 | public void InferSchemaAndValidate_returns_false_and_errorMessage_if_error_in_nested_array()
205 | {
206 |
207 | var jsonString = @"[[1,2,3],[1,2,3]]"; // passing in an array of number arrays.. expecting array of string arrays
208 | var expectedJsonString = @"[[""string"",""string"",""string""],[""string"",""string"",""string""]]";
209 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
210 |
211 | Assert.False(isValid);
212 | Assert.Contains("expected single value of type String but it was Number in a nested array (depth 1)", errorMessages[0]);
213 |
214 | }
215 | [Fact]
216 | public void InferSchemaAndValidate_returns_false_and_errorMessages_If_error_in_nested_object()
217 | {
218 | var jsonString = @"{""id"":1, ""name"":""should be string"", ""child"": {""id"":1, ""name"":2}}"; // name in nested object should be string but is number
219 | var expectedJsonString = @"{""id"":1, ""name"":""should be string"", ""child"": {""id"":1, ""name"":""should be string""}}";
220 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
221 |
222 | Assert.False(isValid);
223 | Assert.Contains("property with name name is Number and expected type is String in a nested object (depth 1)", errorMessages[0]);
224 | }
225 |
226 | [Fact]
227 | public void InferSchemaAndValidate_returns_false_if_nested_array_length_is_shorter_than_expected()
228 | {
229 | var expected = new
230 | {
231 | prop1 = new ArrayList() { "one", "two", "three" }
232 | };
233 | var actual = new
234 | {
235 | prop1 = new ArrayList() { "uno", "dos" }
236 | };
237 | var expectedJsonString = JsonSerializer.Serialize(expected);
238 | var jsonString = JsonSerializer.Serialize(actual);
239 |
240 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new());
241 |
242 | Assert.False(isValid);
243 | Assert.Equal(1, errorMessages.Count);
244 |
245 | Assert.Contains("Expected array to have length 3 but it was 2", errorMessages[0]);
246 | }
247 |
248 | [Fact]
249 | public void InferSchemaAndValidate_returns_true_if_nested_array_length_is_shorter_than_expected_but_ignoreArrayLength()
250 | {
251 | var expected = new
252 | {
253 | prop1 = new ArrayList() { "one", "two", "three" }
254 | };
255 | var actual = new
256 | {
257 | prop1 = new ArrayList() { "one", "two" }
258 | };
259 | var expectedJsonString = JsonSerializer.Serialize(expected);
260 | var jsonString = JsonSerializer.Serialize(actual);
261 |
262 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List() {"--ignore-assert-array-length" }, new());
263 |
264 | Assert.True(isValid);
265 | Assert.Equal(0, errorMessages.Count);
266 | }
267 |
268 | [Fact]
269 | public void InferSchemaAndValidate_can_infer_schema_and_validate_complex_json()
270 | {
271 |
272 | var complexObjectExpected = new
273 | {
274 | prop1 = new
275 | {
276 | prop11 = 1,
277 | prop12 = 2,
278 | prop13 = "hej",
279 | prop14 = 5,
280 | prop15 = new DateTime()
281 | },
282 | propx = new { propx1 = "hellu" },
283 | propy = new { propy1 = "hellu" },
284 | prop2 = new ArrayList(){new {
285 | prop21 = new ArrayList(){"hej", "hopp"}
286 | }, new {
287 | prop22 = new ArrayList(){"hej2", "hopp2"}
288 | }}
289 | };
290 | var complexObject = new
291 | {
292 | prop1 = new
293 | {
294 | prop11 = 1,
295 | prop12 = 2,
296 | prop13 = "hejsan", // not same as "hej" in expected (see customization below..)
297 | prop14 = 6, // not the same as 5 in expected (see customization below..)
298 | prop15 = new DateTime().AddHours(1) // will not be the same as in expected (see customization below)
299 | },
300 | propx = new { propx1 = "hellu" }, // we are missing property propy here...
301 | prop2 = new ArrayList(){new {
302 | prop21 = new ArrayList(){"1", "2"}
303 | }, new {
304 | prop22 = new ArrayList(){"1", 2} // expecting string here... 3 levels deep..
305 | }}
306 | };
307 | var expectedJsonString = JsonSerializer.Serialize(complexObjectExpected);
308 | var jsonString = JsonSerializer.Serialize(complexObject);
309 |
310 | var (isValid, errorMessages) = Interpreter.InferSchemaAndValidate(jsonString, expectedJsonString, new List(), new()
311 | {
312 | new("prop13", 1, CustomizationActions.CompareObjectPropertyValues),
313 | new("prop14", 1, CustomizationActions.CompareObjectPropertyValues),
314 | new("prop15", 1, CustomizationActions.CompareObjectPropertyValues)
315 | });
316 |
317 | Assert.False(isValid);
318 | Assert.Equal(5, errorMessages.Count);
319 |
320 | Assert.Contains("property with name prop13 has the value \"hejsan\" and the expected value is \"hej\" in a nested object (depth 1)", errorMessages[0]);
321 | Assert.Contains("property with name prop14 has the value 6 and the expected value is 5 in a nested object (depth 1)", errorMessages[1]);
322 | Assert.Contains("property with name prop15 has the value", errorMessages[2]);
323 | Assert.Contains("and the expected value is", errorMessages[2]);
324 | Assert.Contains("in a nested object (depth 1)", errorMessages[2]);
325 | Assert.Contains("Object missing property propy of type Object", errorMessages[3]);
326 | Assert.Contains("expected single value of type String but it was Number in a nested array (depth 3)", errorMessages[4]);
327 | }
328 |
329 | }
330 | }
331 |
--------------------------------------------------------------------------------