├── 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 | ![./img/recording.jpg](./img/recording.jpg) 51 | Rumpel listens on port 8181 or the number set in the environment variable **RUMPEL_PORT** 52 | **screenshot** 53 | ![./img/recording.png](./img/recording.png) 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 | ![./img/verifying.jpg](./img/verifying.jpg) 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 | ![./img/successfulVerification.png](./img/successfulVerification.png) 63 | ![./img/failedVerification.png](./img/failedVerification.png) 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 | ![./img/customizations.png](./img/customizations.png) 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 | ![./img/mocking.jpg](./img/mocking.jpg) 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 | ![./img/mocking.png](./img/mocking.png) 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 | ![./img/simulatedConditions.png](./img/simulatedConditions.png) 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 | --------------------------------------------------------------------------------