diff --git a/.changeset/petite-deer-pay.md b/.changeset/petite-deer-pay.md new file mode 100644 index 0000000..83f698d --- /dev/null +++ b/.changeset/petite-deer-pay.md @@ -0,0 +1,8 @@ +--- +"@getlang/parser": patch +"@getlang/ast": patch +"@getlang/get": patch +"@getlang/lib": patch +--- + +flexible urls with implied params diff --git a/packages/ast/src/ast.ts b/packages/ast/src/ast.ts index a3cde17..275f685 100644 --- a/packages/ast/src/ast.ts +++ b/packages/ast/src/ast.ts @@ -48,7 +48,7 @@ type RequestStmt = { export type RequestExpr = { kind: 'RequestExpr' method: Token - url: Expr + url: TemplateExpr headers: RequestBlockExpr blocks: RequestBlockExpr[] body: Expr @@ -214,7 +214,7 @@ const requestStmt = (request: RequestExpr): RequestStmt => ({ const requestExpr = ( method: Token, - url: Expr, + url: TemplateExpr, headers: RequestBlockExpr, blocks: RequestBlockExpr[], body: Expr, diff --git a/packages/get/src/modules.ts b/packages/get/src/modules.ts index 96a13cc..724ef9a 100644 --- a/packages/get/src/modules.ts +++ b/packages/get/src/modules.ts @@ -13,7 +13,6 @@ import { materialize } from './value.js' type Info = { ast: Program - inputs: Set imports: Set isMacro: boolean } @@ -86,7 +85,7 @@ export class Modules { stack: string[], contextType?: TypeInfo, ): Promise { - const { ast, inputs, imports } = await this.getInfo(module) + const { ast, imports } = await this.getInfo(module) const macros: string[] = [] for (const i of imports) { const depInfo = await this.getInfo(i) @@ -94,7 +93,12 @@ export class Modules { macros.push(i) } } - const { program: simplified, calls, modifiers } = desugar(ast, macros) + const { + program: simplified, + inputs, + calls, + modifiers, + } = desugar(ast, macros) const returnTypes: Record = {} for (const call of calls) { diff --git a/packages/lib/src/net/http.ts b/packages/lib/src/net/http.ts index 159d637..21c0020 100644 --- a/packages/lib/src/net/http.ts +++ b/packages/lib/src/net/http.ts @@ -28,6 +28,25 @@ export const requestHook: RequestHook = async (url, opts) => { } } +function constructUrl(start: string, query: StringMap = {}) { + let url: URL + let stripProtocol = false + + try { + url = new URL(start) + } catch (_) { + url = new URL(`http://${start}`) + stripProtocol = true + } + + for (const entry of Object.entries(query)) { + url.searchParams.append(...entry) + } + + const str = url.toString() + return stripProtocol ? str.slice(7) : str +} + export const request = async ( method: string, url: string, @@ -36,14 +55,7 @@ export const request = async ( bodyRaw: string, hook: RequestHook, ) => { - // construct url - const finalUrl = new URL(url) - if (blocks.query) { - for (const entry of Object.entries(blocks.query)) { - finalUrl.searchParams.append(...entry) - } - } - const urlString = finalUrl.toString() + const urlString = constructUrl(url, blocks.query) // construct headers const headers = new Headers(_headers) diff --git a/packages/parser/src/passes/analyze.ts b/packages/parser/src/passes/analyze.ts index fe254fa..fbeae2c 100644 --- a/packages/parser/src/passes/analyze.ts +++ b/packages/parser/src/passes/analyze.ts @@ -3,15 +3,11 @@ import { ScopeTracker, transform } from '@getlang/walker' export function analyze(ast: Program) { const scope = new ScopeTracker() - const inputs = new Set() const imports = new Set() let isMacro = false transform(ast, { scope, - InputExpr(node) { - inputs.add(node.id.value) - }, ModuleExpr(node) { imports.add(node.module.value) }, @@ -23,5 +19,5 @@ export function analyze(ast: Program) { }, }) - return { inputs, imports, isMacro } + return { imports, isMacro } } diff --git a/packages/parser/src/passes/desugar.ts b/packages/parser/src/passes/desugar.ts index d8d02c3..1a6dc56 100644 --- a/packages/parser/src/passes/desugar.ts +++ b/packages/parser/src/passes/desugar.ts @@ -5,6 +5,7 @@ import { dropDrills } from './desugar/dropdrill.js' import { settleLinks } from './desugar/links.js' import { RequestParsers } from './desugar/reqparse.js' import { insertSliceDeps } from './desugar/slicedeps.js' +import { addUrlInputs } from './desugar/urlinputs.js' import { registerCalls } from './inference/calls.js' export type DesugarPass = ( @@ -15,10 +16,15 @@ export type DesugarPass = ( }, ) => Program -function listCalls(ast: Program) { +function analyze2(ast: Program) { + const inputs = new Set() const calls = new Set() const modifiers = new Set() + transform(ast, { + InputExpr(node) { + inputs.add(node.id.value) + }, ModuleExpr(node) { node.call && calls.add(node.module.value) }, @@ -26,10 +32,17 @@ function listCalls(ast: Program) { modifiers.add(node.modifier.value) }, }) - return { calls, modifiers } + + return { inputs, calls, modifiers } } -const visitors = [resolveContext, settleLinks, insertSliceDeps, dropDrills] +const visitors = [ + addUrlInputs, + resolveContext, + settleLinks, + insertSliceDeps, + dropDrills, +] export function desugar(ast: Program, macros: string[] = []) { const parsers = new RequestParsers() @@ -41,6 +54,6 @@ export function desugar(ast: Program, macros: string[] = []) { // inference pass `registerCalls` is included in the desugar phase // it produces the list of called modules required for type inference program = registerCalls(program, macros) - const { calls, modifiers } = listCalls(program) - return { program, calls, modifiers } + const info = analyze2(program) + return { program, ...info } } diff --git a/packages/parser/src/passes/desugar/urlinputs.ts b/packages/parser/src/passes/desugar/urlinputs.ts new file mode 100644 index 0000000..3304b4f --- /dev/null +++ b/packages/parser/src/passes/desugar/urlinputs.ts @@ -0,0 +1,48 @@ +import type { TemplateExpr } from '@getlang/ast' +import { isToken, t } from '@getlang/ast' +import { ScopeTracker, transform } from '@getlang/walker' +import { tx } from '../../utils.js' +import type { DesugarPass } from '../desugar.js' + +export const addUrlInputs: DesugarPass = ast => { + const scope = new ScopeTracker() + const implied = new Set() + + return transform(ast, { + scope, + + RequestExpr: { + enter(node) { + function walkUrl(t: TemplateExpr) { + for (const el of t.elements) { + if (isToken(el)) { + // continue + } else if (el.kind === 'TemplateExpr') { + walkUrl(el) + } else if (el.kind === 'IdentifierExpr') { + const id = el.id.value + if (el.isUrlComponent && !scope.vars[id]) { + implied.add(el.id.value) + } + } + } + } + + walkUrl(node.url) + }, + }, + + Program(node) { + if (implied.size) { + let decl = node.body.find(s => s.kind === 'DeclInputsStmt') + if (!decl) { + decl = t.declInputsStmt([]) + node.body.unshift(decl) + } + for (const i of implied) { + decl.inputs.push(t.InputExpr(tx.token(i), false)) + } + } + }, + }) +} diff --git a/test/expect.ts b/test/expect.ts index aff3fbc..70e1da4 100644 --- a/test/expect.ts +++ b/test/expect.ts @@ -1,31 +1,16 @@ import { expect } from 'bun:test' import { diff } from 'jest-diff' -async function toObject(req: Request) { - return { - url: req.url, - method: req.method, - headers: Object.fromEntries(req.headers), - body: await req.text(), - } -} - expect.extend({ - async toHaveServed(received: unknown, expected: Request) { - const calls: [unknown][] = (received as any)?.mock?.calls - const expObj = await toObject(expected) + async toHaveServed(received: unknown, url: string, opts: RequestInit) { + const calls: [unknown, any][] = (received as any)?.mock?.calls + const { method, headers = {}, body } = opts + const expObj = { url, method, headers, body } let receivedObj: any - for (const [req] of calls) { - if (!(req instanceof Request)) { - return { - pass: false, - message: () => `Received non-Request object: ${req}`, - } - } - - const recObj = await toObject(req) + for (const [url, { method, headers, body }] of calls) { + const recObj = { url, method, headers, body } receivedObj ??= recObj const pass = this.equals(recObj, expObj) if (pass) { diff --git a/test/helpers.ts b/test/helpers.ts index 87e64c0..b783047 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -13,7 +13,7 @@ type ExecuteOptions = Partial<{ willThrow: boolean }> -export type Fetch = (req: Request) => MaybePromise +export type Fetch = (url: string, opts: RequestInit) => MaybePromise export const SELSYN = true @@ -47,7 +47,7 @@ export async function execute( }, async request(url, opts) { invariant(fetch, `Fetch required: ${url}`) - const res = await fetch(new Request(url, opts)) + const res = await fetch(url, opts) return { status: res.status, headers: res.headers, diff --git a/test/request.spec.ts b/test/request.spec.ts index 1c77671..67b5db7 100644 --- a/test/request.spec.ts +++ b/test/request.spec.ts @@ -28,49 +28,43 @@ describe('request', () => { extract -> h1 `) expect(mockFetch).toHaveBeenCalledTimes(1) - await expect(mockFetch).toHaveServed( - new Request('http://get.com', { method: 'GET' }), - ) + await expect(mockFetch).toHaveServed('http://get.com/', { method: 'GET' }) expect(result).toEqual('test') }) test('post', async () => { await execute('POST http://post.com') - await expect(mockFetch).toHaveServed( - new Request('http://post.com', { method: 'POST' }), - ) + await expect(mockFetch).toHaveServed('http://post.com/', { + method: 'POST', + }) }) test('put', async () => { await execute('PUT http://put.com') - await expect(mockFetch).toHaveServed( - new Request('http://put.com', { method: 'PUT' }), - ) + await expect(mockFetch).toHaveServed('http://put.com/', { method: 'PUT' }) }) test('patch', async () => { await execute('PATCH http://patch.com') - await expect(mockFetch).toHaveServed( - new Request('http://patch.com', { method: 'PATCH' }), - ) + await expect(mockFetch).toHaveServed('http://patch.com/', { + method: 'PATCH', + }) }) test('delete', async () => { await execute('DELETE http://delete.com') - await expect(mockFetch).toHaveServed( - new Request('http://delete.com', { method: 'DELETE' }), - ) + await expect(mockFetch).toHaveServed('http://delete.com/', { + method: 'DELETE', + }) }) }) describe('urls', () => { test('literal', async () => { await execute('GET http://get.com') - await expect(mockFetch).toHaveServed( - new Request('http://get.com', { - method: 'GET', - }), - ) + await expect(mockFetch).toHaveServed('http://get.com/', { + method: 'GET', + }) }) test('identifier', async () => { @@ -78,11 +72,9 @@ describe('request', () => { set ident = |'http://ident.com'| GET $ident `) - await expect(mockFetch).toHaveServed( - new Request('http://ident.com/', { - method: 'GET', - }), - ) + await expect(mockFetch).toHaveServed('http://ident.com/', { + method: 'GET', + }) }) test('interpolated', async () => { @@ -91,9 +83,10 @@ describe('request', () => { GET https://boogle.com/search/$query `) await expect(mockFetch).toHaveServed( - new Request('https://boogle.com/search/monterey', { + 'https://boogle.com/search/monterey', + { method: 'GET', - }), + }, ) }) @@ -103,9 +96,10 @@ describe('request', () => { GET https://ging.com/\${query}_results `) await expect(mockFetch).toHaveServed( - new Request('https://ging.com/big%20sur_results', { + 'https://ging.com/big%20sur_results', + { method: 'GET', - }), + }, ) }) @@ -114,9 +108,23 @@ describe('request', () => { set loc = |'
sea ranch
'| -> @html GET https://goto.ca/:loc `) - await expect(mockFetch).toHaveServed( - new Request('https://goto.ca/sea%20ranch', { method: 'GET' }), - ) + await expect(mockFetch).toHaveServed('https://goto.ca/sea%20ranch', { + method: 'GET', + }) + }) + + test('implicit params', async () => { + await execute('GET http://implied.com/projects/:projectId', { + projectId: 12, + }) + await expect(mockFetch).toHaveServed('http://implied.com/projects/12', { + method: 'GET', + }) + }) + + test('non-conforming', async () => { + await execute(`GET example.com`) + expect(mockFetch).toHaveServed('example.com/', { method: 'GET' }) }) }) @@ -129,15 +137,13 @@ describe('request', () => { Accept: application/json `) - await expect(mockFetch).toHaveServed( - new Request('http://api.unweb.com/', { - method: 'GET', - headers: new Headers({ - Authorization: 'Bearer 123', - Accept: 'application/json', - }), + await expect(mockFetch).toHaveServed('http://api.unweb.com/', { + method: 'GET', + headers: new Headers({ + Authorization: 'Bearer 123', + Accept: 'application/json', }), - ) + }) }) describe('blocks', () => { @@ -155,12 +161,13 @@ describe('request', () => { `) await expect(mockFetch).toHaveServed( - new Request('https://example.com/?a=literal&b=b&c=interpolated', { + 'https://example.com/?a=literal&b=b&c=interpolated', + { method: 'GET', headers: new Headers({ 'X-Test': 'true', }), - }), + }, ) }) @@ -173,14 +180,12 @@ describe('request', () => { c: /here&we!are? `) - await expect(mockFetch).toHaveServed( - new Request('https://example.com/', { - method: 'GET', - headers: new Headers({ - Cookie: 'a=A; b=123; c=%2Fhere%26we%21are%3F', - }), + await expect(mockFetch).toHaveServed('https://example.com/', { + method: 'GET', + headers: new Headers({ + Cookie: 'a=A; b=123; c=%2Fhere%26we%21are%3F', }), - ) + }) }) test('json body', async () => { @@ -191,12 +196,10 @@ describe('request', () => { password: test `) - await expect(mockFetch).toHaveServed( - new Request('https://example.com/login', { - method: 'POST', - body: '{"username":"admin","password":"test"}', - }), - ) + await expect(mockFetch).toHaveServed('https://example.com/login', { + method: 'POST', + body: '{"username":"admin","password":"test"}', + }) }) test('raw body', async () => { @@ -212,13 +215,11 @@ describe('request', () => { [/body] `) - await expect(mockFetch).toHaveServed( - new Request('https://example.com/', { - method: 'POST', - headers: new Headers(), - body: "hello\n g'day\n welcome\n", - }), - ) + await expect(mockFetch).toHaveServed('https://example.com/', { + method: 'POST', + headers: new Headers(), + body: "hello\n g'day\n welcome\n", + }) }) test('omits undefined', async () => { @@ -239,16 +240,14 @@ describe('request', () => { bar: bar `) - await expect(mockFetch).toHaveServed( - new Request('https://example.com/?bar=bar', { - method: 'POST', - headers: new Headers({ - 'X-Bar': 'bar', - Cookie: 'bar=bar', - }), - body: '{"bar":"bar"}', + await expect(mockFetch).toHaveServed('https://example.com/?bar=bar', { + method: 'POST', + headers: new Headers({ + 'X-Bar': 'bar', + Cookie: 'bar=bar', }), - ) + body: '{"bar":"bar"}', + }) }) test('optional template groups', async () => { @@ -261,15 +260,13 @@ describe('request', () => { X-Baz: baz$[$foo]zza `) - await expect(mockFetch).toHaveServed( - new Request('https://example.com/pre/post', { - method: 'GET', - headers: new Headers({ - 'X-Bar': 'bar', - 'X-Baz': 'bazzza', - }), + await expect(mockFetch).toHaveServed('https://example.com/pre/post', { + method: 'GET', + headers: new Headers({ + 'X-Bar': 'bar', + 'X-Baz': 'bazzza', }), - ) + }) }) test('nested template parts', async () => { @@ -285,33 +282,25 @@ describe('request', () => { await execute(src, { y: '0y0' }) await execute(src, { x: '0x0', y: '0y0' }) - await expect(mockFetch).toHaveServed( - new Request('https://getlang.dev/', { - method: 'GET', - headers: new Headers({ Header: 'aagg' }), - }), - ) + await expect(mockFetch).toHaveServed('https://getlang.dev/', { + method: 'GET', + headers: new Headers({ Header: 'aagg' }), + }) - await expect(mockFetch).toHaveServed( - new Request('https://getlang.dev/', { - method: 'GET', - headers: new Headers({ Header: 'aabb0x0ccffgg' }), - }), - ) + await expect(mockFetch).toHaveServed('https://getlang.dev/', { + method: 'GET', + headers: new Headers({ Header: 'aabb0x0ccffgg' }), + }) - await expect(mockFetch).toHaveServed( - new Request('https://getlang.dev/', { - method: 'GET', - headers: new Headers({ Header: 'aagg' }), - }), - ) + await expect(mockFetch).toHaveServed('https://getlang.dev/', { + method: 'GET', + headers: new Headers({ Header: 'aagg' }), + }) - await expect(mockFetch).toHaveServed( - new Request('https://getlang.dev/', { - method: 'GET', - headers: new Headers({ Header: 'aabb0x0ccdd0y0eeffgg' }), - }), - ) + await expect(mockFetch).toHaveServed('https://getlang.dev/', { + method: 'GET', + headers: new Headers({ Header: 'aabb0x0ccdd0y0eeffgg' }), + }) }) }) diff --git a/test/test.d.ts b/test/test.d.ts index 0ccc9a3..9179047 100644 --- a/test/test.d.ts +++ b/test/test.d.ts @@ -1,8 +1,8 @@ declare module 'bun:test' { interface AsymmetricMatchers { - toHaveServed(request: Request): void + toHaveServed(url: string, opts: RequestInit): void } interface Matchers { - toHaveServed(request: Request): R + toHaveServed(url: string, opts: RequestInit): R } }