Skip to content

Commit

Permalink
Merge pull request #691 from pmcelhaney/the-xml-problem
Browse files Browse the repository at this point in the history
Partial support for XML
  • Loading branch information
pmcelhaney committed Dec 24, 2023
2 parents c6d0ebb + fbce77d commit f62a693
Show file tree
Hide file tree
Showing 10 changed files with 262 additions and 67 deletions.
5 changes: 5 additions & 0 deletions .changeset/lemon-actors-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"counterfact": minor
---

Partial support for XML (if anyone still uses XML). See https://swagger.io/docs/specification/data-models/representing-xml/
13 changes: 12 additions & 1 deletion bin/counterfact.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
#!/usr/bin/env node

import { readFile } from "node:fs/promises";
import nodePath from "node:path";
import { fileURLToPath } from "node:url";

import { program } from "commander";
import createDebug from "debug";
import open from "open";

import { migrate } from "../dist/migrations/0.27.js";
import { counterfact } from "../dist/server/app.js";
import { taglines } from "../dist/server/taglines.js";

const taglinesFile = await readFile(
nodePath.join(
nodePath.dirname(fileURLToPath(import.meta.url)),
"taglines.txt",
),
"utf8",
);

const taglines = taglinesFile.split("\n").slice(0, -1);

const DEFAULT_PORT = 3100;

Expand Down
47 changes: 47 additions & 0 deletions bin/taglines.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
high code, low effort mock REST APIs
Are you mocking me?
stop, mock, and roll
fake it till you make it
things are about to get real
when opportunity mocks
your time is very important to us
This is an HTTP joke. GET it?
the REPL alliance
there is no spoon
what you say is what you get
pay no attention to man behind the curtain
at your service
I'm afraid I can do that, Dave
Hey! I'm mockin' here!
all mock no friction
a cubic zirconia in the rough
maybe she’s born with it…
made on an Apple in Carolina
a Three Kids in a Trench Coat production
a little sleight of &&
there’s no place like localhost
my favorite book is Charlatan’s Web
fact follows fiction
your lovely assistant
my other server’s a Kubernetes cluster
you wrote it here first
here lies “waiting on back-end”; REST in peace
your front-end’s REST friend
first past the post
🎶 mockin' the casbah 🎶
you ready to mock and roll?
I also do weddings and bar mocks-vahs
I’m thinking about going into fake estate
I bet you’re real fun at mocktail parties
Eat. Sleep. Mock. Repeat.
hand over the cache and no one gets hurt
Storybook for the back-end
you are now free to mock about the cabin
close enough for government work
an API in hand is worth two in the cloud
where we're going, we don't need wifi
paper prototyping the back-end
the cloud natives are restless
your front-end dev flight simulator
your front-end’s sparring partner
your back-end's stunt double
92 changes: 92 additions & 0 deletions src/server/json-to-xml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
interface XmlHints {
attribute?: boolean;
name?: string;
namespace?: string;
prefix?: string;
wrapped?: boolean;
}

interface Schema {
[key: string]: unknown;
items?: Schema;
properties?: {
[key: string]: Schema;
};
xml?: XmlHints;
}

function xmlEscape(xmlString: string): string {
// eslint-disable-next-line unicorn/prefer-string-replace-all
return xmlString.replace(/["&'<>]/gu, (character: string) => {
switch (character) {
case "<": {
return "&lt;";
}
case ">": {
return "&gt;";
}
case "&": {
return "&amp;";
}
case "'": {
return "&apos;";
}
case '"': {
return "&quot;";
}
default: {
return character;
}
}
});
}

function objectToXml(
json: object,
schema: Schema | undefined,
name: string,
): string {
const xml: string[] = [];

const attributes: string[] = [];

Object.entries(json).forEach(([key, value]) => {
const properties = schema?.properties?.[key];
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (properties?.attribute) {
attributes.push(` ${key}="${xmlEscape(String(value))}"`);
} else {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
xml.push(jsonToXml(value, properties, key));
}
});

return `<${name}${attributes.join("")}>${String(xml.join(""))}</${name}>`;
}

export function jsonToXml(
json: unknown,
schema: Schema | undefined,
keyName = "root",
): string {
const name = schema?.xml?.name ?? keyName;

if (Array.isArray(json)) {
const items = json
.map((item) => jsonToXml(item, schema?.items, name))
.join("");

// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
if (schema?.xml?.wrapped) {
return `<${name}>${items}</${name}>`;
}

return items;
}

if (typeof json === "object" && json !== null) {
return objectToXml(json, schema, name);
}

return `<${name}>${String(json)}</${name}>`;
}
50 changes: 38 additions & 12 deletions src/server/response-builder.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { JSONSchemaFaker } from "json-schema-faker";

import { jsonToXml } from "./json-to-xml.js";
import type { OpenApiOperation, ResponseBuilder } from "./types.d.ts";

JSONSchemaFaker.option("useExamplesValue", true);
JSONSchemaFaker.option("minItems", 0);
JSONSchemaFaker.option("maxItems", 20);

function convertToXmlIfNecessary(
type: string,
body: unknown,
schema?: { [key: string]: unknown },
) {
if (type.endsWith("/xml")) {
return jsonToXml(body, schema, "root");
}

return body;
}

function oneOf(items: unknown[] | { [key: string]: unknown }): unknown {
if (Array.isArray(items)) {
return items[Math.floor(Math.random() * items.length)];
Expand Down Expand Up @@ -71,7 +84,14 @@ export function createResponseBuilder(
content: [
...(this.content ?? []),
{
body,
body: convertToXmlIfNecessary(
contentType,
body,
operation.responses[this.status ?? "default"]?.content?.[
contentType
]?.schema,
),

type: contentType,
},
],
Expand All @@ -97,16 +117,19 @@ export function createResponseBuilder(
...this,

content: Object.keys(content).map((type) => ({
body: content[type]?.examples
? oneOf(
Object.values(content[type]?.examples ?? []).map(
(example) => example.value,
body: convertToXmlIfNecessary(
type,
content[type]?.examples
? oneOf(
Object.values(content[type]?.examples ?? []).map(
(example) => example.value,
),
)
: JSONSchemaFaker.generate(
content[type]?.schema ?? { type: "object" },
),
)
: JSONSchemaFaker.generate(
// eslint-disable-next-line total-functions/no-unsafe-readonly-mutable-assignment
content[type]?.schema ?? { type: "object" },
),
content[type]?.schema,
),

type,
})),
Expand All @@ -124,8 +147,7 @@ export function createResponseBuilder(

const body = response.examples
? oneOf(response.examples)
: // eslint-disable-next-line total-functions/no-unsafe-readonly-mutable-assignment
JSONSchemaFaker.generate(response.schema ?? { type: "object" });
: JSONSchemaFaker.generate(response.schema ?? { type: "object" });

return {
...this,
Expand All @@ -142,6 +164,10 @@ export function createResponseBuilder(
text(this: ResponseBuilder, body: unknown) {
return this.match("text/plain", body);
},

xml(this: ResponseBuilder, body: unknown) {
return this.match("text/xml", body);
},
}),
});
}
Expand Down
51 changes: 0 additions & 51 deletions src/server/taglines.ts

This file was deleted.

5 changes: 3 additions & 2 deletions src/server/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ interface ResponseBuilder {
randomLegacy: () => ResponseBuilder;
status?: number;
text: (body: unknown) => ResponseBuilder;
xml: (body: unknown) => ResponseBuilder;
}

type GenericResponseBuilder<
Expand Down Expand Up @@ -181,11 +182,11 @@ interface OpenApiOperation {
content?: {
[type: number | string]: {
examples?: { [key: string]: Example };
schema: unknown;
schema: { [key: string]: unknown };
};
};
examples?: { [key: string]: unknown };
schema?: unknown;
schema?: { [key: string]: unknown };
};
};
}
Expand Down
1 change: 1 addition & 0 deletions templates/response-builder-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type ResponseBuilder<
: MatchFunction<Response>;
random: [keyof Response["content"]] extends [never] ? never : () => void;
text: MaybeShortcut<"text/plain", Response>;
xml: MaybeShortcut<"text/xml", Response>;
}>;

export type ResponseBuilderFactory<
Expand Down
Loading

0 comments on commit f62a693

Please sign in to comment.