diff --git a/property_tests/arbitraries/candid/primitive/floats/float32_arb.ts b/property_tests/arbitraries/candid/primitive/floats/float32_arb.ts index 28396ba0ff..1ccb5d78f5 100644 --- a/property_tests/arbitraries/candid/primitive/floats/float32_arb.ts +++ b/property_tests/arbitraries/candid/primitive/floats/float32_arb.ts @@ -23,7 +23,14 @@ export function Float32DefinitionArb(): fc.Arbitrary { // TODO the agent should encode and decode -0 correctly export function Float32ValueArb(): fc.Arbitrary> { return SimpleCandidValuesArb( - fc.float().map((sample) => (sample === 0 ? sample * 0 : sample)), + fc + .float32Array({ + maxLength: 1, + minLength: 1, + noDefaultInfinity: true, + noNaN: true + }) + .map(([sample]) => (sample === 0 ? sample * 0 : sample)), floatToSrcLiteral ); } diff --git a/property_tests/arbitraries/canister_methods/query_method_arb.ts b/property_tests/arbitraries/canister_methods/query_method_arb.ts index a7595f04b2..0b292dd0fb 100644 --- a/property_tests/arbitraries/canister_methods/query_method_arb.ts +++ b/property_tests/arbitraries/canister_methods/query_method_arb.ts @@ -51,6 +51,7 @@ export function QueryMethodArb< ReturnTypeAgentResponseValue >; callbackLocation?: CallbackLocation; + name?: string; } ) { return fc @@ -66,7 +67,7 @@ export function QueryMethodArb< ) .map( ([ - functionName, + defaultFunctionName, paramTypes, returnType, defaultCallbackLocation, @@ -74,6 +75,7 @@ export function QueryMethodArb< ]): QueryMethod => { const callbackLocation = constraints.callbackLocation ?? defaultCallbackLocation; + const functionName = constraints.name ?? defaultFunctionName; const imports = new Set([ 'query', diff --git a/property_tests/arbitraries/http/body_arb.ts b/property_tests/arbitraries/http/body_arb.ts new file mode 100644 index 0000000000..ae8a711cb6 --- /dev/null +++ b/property_tests/arbitraries/http/body_arb.ts @@ -0,0 +1,5 @@ +import fc from 'fast-check'; + +export function BodyArb() { + return fc.json().map((json) => new Uint8Array(Buffer.from(json, 'utf-8'))); +} diff --git a/property_tests/arbitraries/http/headers_arb.ts b/property_tests/arbitraries/http/headers_arb.ts new file mode 100644 index 0000000000..e1fedeef9c --- /dev/null +++ b/property_tests/arbitraries/http/headers_arb.ts @@ -0,0 +1,15 @@ +// See https://developers.cloudflare.com/rules/transform/request-header-modification/reference/header-format/ + +import fc from 'fast-check'; + +export function HttpHeadersArb() { + const HeaderNameArb = fc.stringMatching(/^[a-zA-Z0-9_-]+$/); + + const HeaderValueArb = fc + .stringMatching(/^[a-zA-Z0-9_ :;.,\/"'?!(){}[\]@<>=\-+*#$&`|~^%]+$/) + .map((value) => value.trim()); + + const HeaderArb = fc.tuple(HeaderNameArb, HeaderValueArb); + + return fc.array(HeaderArb); +} diff --git a/property_tests/arbitraries/http/request_arb.ts b/property_tests/arbitraries/http/request_arb.ts new file mode 100644 index 0000000000..9e701e1e09 --- /dev/null +++ b/property_tests/arbitraries/http/request_arb.ts @@ -0,0 +1,103 @@ +import fc from 'fast-check'; +import { HttpHeadersArb } from './headers_arb'; +import { BodyArb } from './body_arb'; +import { HttpRequest, None, Some } from '../../../src/lib'; +import { CandidValueAndMeta } from '../candid/candid_value_and_meta_arb'; +import { blobToSrcLiteral } from '../candid/to_src_literal/blob'; +import { stringToSrcLiteral } from '../candid/to_src_literal/string'; + +type RequestMethod = + | 'GET' + | 'HEAD' + | 'POST' + | 'PUT' + | 'DELETE' + | 'CONNECT' + | 'OPTIONS' + | 'TRACE' + | 'PATCH'; + +const RequestMethodArb = fc.constantFrom( + 'GET', + 'HEAD', + 'POST', + 'PUT', + 'DELETE', + 'CONNECT', + 'OPTIONS', + 'TRACE', + 'PATCH' +); + +const UrlArb = fc.webUrl({ withQueryParameters: true }).map((url) => { + const parsedUrl = new URL(url); + return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash; +}); + +function HttpRequestValueArb() { + return fc + .tuple( + RequestMethodArb, + UrlArb, + HttpHeadersArb(), + BodyArb(), + fc + .option(fc.integer({ min: 0, max: 2 ** 16 - 1 })) + .map((optCertVer) => { + return optCertVer === null ? None : Some(optCertVer); + }) + ) + .map(([method, url, headers, body, certificate_version]) => { + return { + method, + url, + headers, + body, + certificate_version + }; + }); +} + +export function HttpRequestArb(): fc.Arbitrary< + CandidValueAndMeta +> { + return HttpRequestValueArb().map((httpRequest) => { + const headerStrings = httpRequest.headers + .map( + ([name, value]) => + `[${stringToSrcLiteral(name)},${stringToSrcLiteral(value)}]` + ) + .join(','); + + const bodySrc = blobToSrcLiteral(httpRequest.body); + + const certificateVersion = + 'Some' in httpRequest.certificate_version + ? `Some(${httpRequest.certificate_version.Some})` + : `None`; + + const optImport = + 'Some' in httpRequest.certificate_version ? 'Some' : 'None'; + + return { + value: { + agentArgumentValue: httpRequest, + agentResponseValue: httpRequest, + candidTypeObject: HttpRequest + }, + src: { + candidTypeAnnotation: 'HttpRequest', + candidTypeObject: 'HttpRequest', + variableAliasDeclarations: [], + imports: new Set(['HttpRequest', optImport]), + valueLiteral: `{ + method: '${httpRequest.method}', + url: '${httpRequest.url}', + headers: [${headerStrings}], + body: ${bodySrc}, + certificate_version: ${certificateVersion} + }` + } + }; + }); +} diff --git a/property_tests/arbitraries/http/response_arb.ts b/property_tests/arbitraries/http/response_arb.ts new file mode 100644 index 0000000000..2cba44865b --- /dev/null +++ b/property_tests/arbitraries/http/response_arb.ts @@ -0,0 +1,84 @@ +import fc from 'fast-check'; +import { HttpResponse, None } from '../../../src/lib'; +import { HttpHeadersArb } from './headers_arb'; +import { BodyArb } from './body_arb'; +import { CandidValueAndMeta } from '../candid/candid_value_and_meta_arb'; +import { CorrespondingJSType } from '../candid/corresponding_js_type'; +import { blobToSrcLiteral } from '../candid/to_src_literal/blob'; +import { stringToSrcLiteral } from '../candid/to_src_literal/string'; + +export type HttpResponseAgentResponseValue = { + status: number; + headers: [string, string][]; + body: string; +}; + +// The ic replica doesn't support returning status codes in the 1xx range. +const StatusCodeArb = fc.integer({ min: 200, max: 599 }); + +export function HttpResponseValueArb() { + return fc + .tuple(StatusCodeArb, HttpHeadersArb(), BodyArb()) + .map(([status_code, headers, body]): HttpResponse => { + const thing: HttpResponse = { + status_code, + headers, + body, + upgrade: None, + streaming_strategy: None + }; + return thing; + }); +} +export function HttpResponseArb( + token: CandidValueAndMeta +): fc.Arbitrary< + CandidValueAndMeta, HttpResponseAgentResponseValue> +> { + return HttpResponseValueArb().map((response) => { + const lowerCasedHeaders = response.headers.map<[string, string]>( + ([name, value]) => [name.toLowerCase(), value] + ); + + const agentResponseValue = { + status: response.status_code, + headers: lowerCasedHeaders, + body: new TextDecoder().decode(response.body) + }; + + const headerStrings = response.headers + .map( + ([name, value]) => + `[${stringToSrcLiteral(name)},${stringToSrcLiteral(value)}]` + ) + .join(','); + + const bodySrc = blobToSrcLiteral(response.body); + + return { + value: { + agentArgumentValue: response, + agentResponseValue: agentResponseValue, + candidTypeObject: HttpResponse(token.value.candidTypeObject) + }, + src: { + candidTypeAnnotation: `HttpResponse<${token.src.candidTypeAnnotation}>`, + candidTypeObject: `HttpResponse(${token.src.candidTypeObject})`, + variableAliasDeclarations: token.src.variableAliasDeclarations, + imports: new Set([ + 'HttpResponse', + 'bool', + 'None', + ...token.src.imports + ]), + valueLiteral: `{ + status_code: ${response.status_code}, + headers: [${headerStrings}], + body: ${bodySrc}, + upgrade: None, + streaming_strategy: None + }` + } + }; + }); +} diff --git a/property_tests/tests/canister_methods/http_request/test/fletch.ts b/property_tests/tests/canister_methods/http_request/test/fletch.ts new file mode 100644 index 0000000000..e5151bda04 --- /dev/null +++ b/property_tests/tests/canister_methods/http_request/test/fletch.ts @@ -0,0 +1,53 @@ +import { execSync } from 'child_process'; +import { HttpRequest } from 'azle'; + +/** + * A synchronous "fetch" for canisters. + */ +export function fletch(canisterName: string, options: HttpRequest) { + const canisterId = getCanisterId(canisterName); + + const requestHeaders = options.headers + .map( + ([name, value]) => + `-H "${escapeForBash(name)}: ${escapeForBash(value)}"` + ) + .join(' '); + + const curlCommand = `curl\ + --silent\ + --include\ + -X ${options.method}\ + ${requestHeaders}\ + --data "${options.body.join(',')}"\ + "${canisterId}.localhost:8000${options.url}" \ + --resolve "${canisterId}.localhost:8000:127.0.0.1"`; + + const responseBytes = execSync(curlCommand); + const decoder = new TextDecoder(); + const response = decoder.decode(responseBytes); + const [statusCodeAndHeaders, body] = response.split('\r\n\r\n'); + const [statusCodeString, ...responseHeaderStrings] = + statusCodeAndHeaders.split('\r\n'); + const statusCode = Number(statusCodeString.split(' ')[1]); + const responseHeaders = responseHeaderStrings.map((header) => + header.split(': ') + ); + return { + status: statusCode, + headers: responseHeaders, + body + }; +} + +function getCanisterId(canisterName: string): string { + return execSync(`dfx canister id ${canisterName}`).toString().trim(); +} + +function escapeForBash(input: string) { + return input + .replace(/\\/g, '\\\\') // Escape backslashes + .replace(/`/g, '\\`') // Escape backticks + .replace(/'/g, "'\\''") // Escape single quotes + .replace(/"/g, '\\"'); // Escape double quotes +} diff --git a/property_tests/tests/canister_methods/http_request/test/generate_body.ts b/property_tests/tests/canister_methods/http_request/test/generate_body.ts new file mode 100644 index 0000000000..c04a95126f --- /dev/null +++ b/property_tests/tests/canister_methods/http_request/test/generate_body.ts @@ -0,0 +1,70 @@ +import { HttpRequest } from 'azle'; +import { CandidReturnType } from 'azle/property_tests/arbitraries/candid/candid_return_type_arb'; +import { CandidValueAndMeta } from 'azle/property_tests/arbitraries/candid/candid_value_and_meta_arb'; +import { Named } from 'azle/property_tests'; + +export function generateBody( + namedParams: Named>[], + returnType: CandidValueAndMeta +): string { + const { name: requestParamName, el: requestValueObject } = namedParams[0]; + const request = requestValueObject.value.agentArgumentValue; + + const headersMap = + request.headers.length === 0 + ? '' + : `const headers = (${requestParamName}.headers as [string, string][]).reduce<{ + [key: string]: string} + >((prev, [name, value]) => ({[name]: value, ...prev}), {});`; + + const headerChecks = request.headers + .map(([name, value]) => { + return ` + if (headers['${escape(name).toLowerCase()}'] !== '${escape( + value + )}') { + throw new Error( + \`Unexpected value for header '${escape( + name + )}'. Expected '${escape( + value + )}' but received '\${headers['${escape( + name + ).toLowerCase()}']}'\` + ); + } + `; + }) + .join('\n'); + + return ` + if (${requestParamName}.method !== '${request.method}') { + throw new Error( + \`Unexpected req.method. Expected ${ + request.method + } but received \${${requestParamName}.method}\` + ); + } + if (${requestParamName}.url !== '${escape(request.url)}') { + throw new Error( + \`Unexpected req.url. Expected '${escape( + request.url + )}' but received \${${requestParamName}.url}\` + ); + } + + ${headersMap} + + ${headerChecks} + + return ${returnType.src.valueLiteral}; + `; +} + +function escape(input: string) { + return input + .replace(/\\/g, '\\\\') // Escape backslashes + .replace(/`/g, '\\`') // Escape backticks + .replace(/'/g, "\\'") // Escape single quotes + .replace(/"/g, '\\"'); // Escape double quotes +} diff --git a/property_tests/tests/canister_methods/http_request/test/generate_tests.ts b/property_tests/tests/canister_methods/http_request/test/generate_tests.ts new file mode 100644 index 0000000000..46bb2e0ae0 --- /dev/null +++ b/property_tests/tests/canister_methods/http_request/test/generate_tests.ts @@ -0,0 +1,47 @@ +import { HttpRequest, HttpResponse } from 'azle'; +import { deepEqual, Named } from 'azle/property_tests'; +import { CandidValueAndMeta } from 'azle/property_tests/arbitraries/candid/candid_value_and_meta_arb'; +import { Test } from 'azle/test'; + +import { fletch } from './fletch'; +import { HttpResponseAgentResponseValue } from '../../../../arbitraries/http/response_arb'; + +export function generateTests( + functionName: string, + params: Named>[], + returnType: CandidValueAndMeta< + HttpResponse, + HttpResponseAgentResponseValue + > +): Test[][] { + const request = params[0].el.value.agentArgumentValue; + const expectedResponse = returnType.value.agentResponseValue; + + return [ + [ + { + name: functionName, + test: async () => { + const response = fletch('canister', request); + const filteredHeaders = response.headers.filter( + ([name]) => + name !== 'x-ic-streaming-response' && + name !== 'content-length' && + name !== 'date' + ); + const processedResponse = { + status: response.status, + headers: filteredHeaders, + body: response.body + }; + const valuesAreEqual = deepEqual( + processedResponse, + expectedResponse + ); + + return { Ok: valuesAreEqual }; + } + } + ] + ]; +} diff --git a/property_tests/tests/canister_methods/http_request/test/test.ts b/property_tests/tests/canister_methods/http_request/test/test.ts new file mode 100644 index 0000000000..5faf83fa1f --- /dev/null +++ b/property_tests/tests/canister_methods/http_request/test/test.ts @@ -0,0 +1,37 @@ +import fc from 'fast-check'; + +import { HttpRequest } from 'azle'; +import { runPropTests } from 'azle/property_tests'; +import { + CanisterArb, + CanisterConfig +} from 'azle/property_tests/arbitraries/canister_arb'; +import { HttpRequestArb } from 'azle/property_tests/arbitraries/http/request_arb'; +import { HttpResponseArb } from 'azle/property_tests/arbitraries/http/response_arb'; +import { QueryMethodArb } from 'azle/property_tests/arbitraries/canister_methods/query_method_arb'; +import { RecordArb } from 'azle/property_tests/arbitraries/candid/constructed/record_arb'; + +import { generateBody } from './generate_body'; +import { generateTests } from './generate_tests'; + +const CanisterConfigArb = RecordArb() + .chain((record) => { + const HttpRequestMethodArb = QueryMethodArb( + fc.tuple(HttpRequestArb()), + HttpResponseArb(record), + { + name: 'http_request', + generateBody, + generateTests + } + ); + + return HttpRequestMethodArb; + }) + .map((httpRequestMethod): CanisterConfig => { + return { + queryMethods: [httpRequestMethod] + }; + }); + +runPropTests(CanisterArb(CanisterConfigArb));