Openstatus www.openstatus.dev

feat: curl builder tool (#1098)

* chore: curl tool

* chore: changelog and search params

authored by

Maximilian Kaske and committed by
GitHub
f2ee3bac c87fff16

+433 -7
apps/web/public/assets/changelog/curl-builder-playground.png

This is a binary file and will not be displayed.

+22 -6
apps/web/src/app/play/checker/page.tsx
··· 1 1 import type { Metadata } from "next"; 2 2 3 + import { 4 + defaultMetadata, 5 + ogMetadata, 6 + twitterMetadata, 7 + } from "@/app/shared-metadata"; 3 8 import { BottomCTA } from "@/components/marketing/in-between-cta"; 4 9 import { getCheckerDataById } from "@/components/ping-response-analysis/utils"; 5 10 import { redirect } from "next/navigation"; ··· 8 13 import { Testimonial } from "./_components/testimonial"; 9 14 import { searchParamsCache } from "./search-params"; 10 15 16 + const title = "Global Speed Checker"; 17 + const description = 18 + "Test the performance of your api, website from multiple regions. Get speed insights for free."; 19 + 11 20 export const metadata: Metadata = { 12 - title: "Global Speed Checker", 13 - description: 14 - "Test the performance of your api, website from multiple regions. Get speed insights for free.", 21 + ...defaultMetadata, 22 + title, 23 + description, 24 + twitter: { 25 + ...twitterMetadata, 26 + title, 27 + description, 28 + images: [`/api/og?title=${title}&description=${description}`], 29 + }, 15 30 openGraph: { 16 - title: "Global Speed Checker", 17 - description: 18 - "Test the performance of your api, website from multiple regions. Get speed insights for free.", 31 + ...ogMetadata, 32 + title, 33 + description, 34 + images: [`/api/og?title=${title}&description=${description}`], 19 35 }, 20 36 }; 21 37
+297
apps/web/src/app/play/curl/_components/curl-form.tsx
··· 1 + "use client"; 2 + 3 + import { useDebounce } from "@/hooks/use-debounce"; 4 + import { toast } from "@/lib/toast"; 5 + import { copyToClipboard } from "@/lib/utils"; 6 + import { zodResolver } from "@hookform/resolvers/zod"; 7 + import { 8 + Button, 9 + Checkbox, 10 + Form, 11 + FormControl, 12 + FormDescription, 13 + FormField, 14 + FormItem, 15 + FormLabel, 16 + FormMessage, 17 + Input, 18 + Select, 19 + SelectContent, 20 + SelectItem, 21 + SelectTrigger, 22 + SelectValue, 23 + Separator, 24 + Textarea, 25 + } from "@openstatus/ui"; 26 + import { X } from "lucide-react"; 27 + import { useCallback } from "react"; 28 + import { useFieldArray, useForm } from "react-hook-form"; 29 + import { z } from "zod"; 30 + 31 + const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; 32 + 33 + const formSchema = z.object({ 34 + method: z.enum(methods).default("GET"), 35 + url: z.string().url().default(""), 36 + body: z.string().default(""), 37 + verbose: z.boolean().default(false), 38 + insecure: z.boolean().default(false), 39 + json: z.boolean().default(false), 40 + headers: z 41 + .array(z.object({ key: z.string(), value: z.string() })) 42 + .default([]), 43 + }); 44 + 45 + export function CurlForm({ 46 + defaultValues, 47 + }: { 48 + defaultValues?: z.infer<typeof formSchema>; 49 + }) { 50 + const form = useForm<z.infer<typeof formSchema>>({ 51 + resolver: zodResolver(formSchema), 52 + defaultValues, 53 + }); 54 + const { fields, append, remove } = useFieldArray({ 55 + name: "headers", 56 + control: form.control, 57 + }); 58 + 59 + const formValues = form.watch(); 60 + const debouncedformValues = useDebounce(formValues, 300); 61 + 62 + const onSubmit = useCallback((values: z.infer<typeof formSchema>) => { 63 + copyToClipboard(generateCurlCommand(values)); 64 + toast("CURL copied to clipboard"); 65 + }, []); 66 + 67 + return ( 68 + <Form {...form}> 69 + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3"> 70 + <FormField 71 + control={form.control} 72 + name="method" 73 + render={({ field }) => ( 74 + <FormItem> 75 + <FormLabel>Method</FormLabel> 76 + <Select onValueChange={field.onChange} defaultValue={field.value}> 77 + <FormControl> 78 + <SelectTrigger> 79 + <SelectValue placeholder="Select an HTTP method" /> 80 + </SelectTrigger> 81 + </FormControl> 82 + <SelectContent> 83 + {methods.map((method) => ( 84 + <SelectItem key={method} value={method}> 85 + {method} 86 + </SelectItem> 87 + ))} 88 + </SelectContent> 89 + </Select> 90 + <FormDescription> 91 + Specify the request method to use. 92 + </FormDescription> 93 + <FormMessage /> 94 + </FormItem> 95 + )} 96 + /> 97 + <FormField 98 + control={form.control} 99 + name="url" 100 + render={({ field }) => ( 101 + <FormItem> 102 + <FormLabel>URL</FormLabel> 103 + <FormControl> 104 + <Input placeholder="https://openstat.us" {...field} /> 105 + </FormControl> 106 + <FormDescription>The URL to send the request to.</FormDescription> 107 + <FormMessage /> 108 + </FormItem> 109 + )} 110 + /> 111 + <div className="space-y-2 sm:col-span-full"> 112 + <FormLabel>Request Header</FormLabel> 113 + {fields.map((field, index) => ( 114 + <div key={field.id} className="grid grid-cols-6 gap-4"> 115 + <FormField 116 + control={form.control} 117 + name={`headers.${index}.key`} 118 + render={({ field }) => ( 119 + <FormItem className="col-span-2"> 120 + <FormControl> 121 + <Input placeholder="Authorization" {...field} /> 122 + </FormControl> 123 + </FormItem> 124 + )} 125 + /> 126 + <div className="col-span-4 flex items-center space-x-2"> 127 + <FormField 128 + control={form.control} 129 + name={`headers.${index}.value`} 130 + render={({ field }) => ( 131 + <FormItem className="w-full"> 132 + <FormControl> 133 + <Input placeholder="Bearer <your-token>" {...field} /> 134 + </FormControl> 135 + </FormItem> 136 + )} 137 + /> 138 + <Button 139 + size="icon" 140 + variant="ghost" 141 + type="button" 142 + onClick={() => remove(index)} 143 + > 144 + <X className="h-4 w-4" /> 145 + </Button> 146 + </div> 147 + </div> 148 + ))} 149 + <div> 150 + <Button 151 + type="button" 152 + variant="secondary" 153 + size="sm" 154 + onClick={() => append({ key: "", value: "" })} 155 + > 156 + Add a custom header 157 + </Button> 158 + </div> 159 + </div> 160 + <FormField 161 + control={form.control} 162 + name="body" 163 + render={({ field }) => ( 164 + <FormItem> 165 + <FormLabel>Body</FormLabel> 166 + <FormControl> 167 + <Textarea 168 + placeholder={`{ "status": "operational" }`} 169 + {...field} 170 + /> 171 + </FormControl> 172 + <FormDescription> 173 + The data payload to send with the request. 174 + </FormDescription> 175 + <FormMessage /> 176 + </FormItem> 177 + )} 178 + /> 179 + <Separator /> 180 + <FormField 181 + control={form.control} 182 + name="json" 183 + render={({ field }) => ( 184 + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> 185 + <FormControl> 186 + <Checkbox 187 + checked={field.value} 188 + onCheckedChange={field.onChange} 189 + /> 190 + </FormControl> 191 + <div className="space-y-1 leading-none"> 192 + <FormLabel>JSON Content-Type</FormLabel> 193 + <FormDescription> 194 + Set the <code>Content-Type</code> header to{" "} 195 + <code>application/json</code>. 196 + </FormDescription> 197 + </div> 198 + </FormItem> 199 + )} 200 + /> 201 + <FormField 202 + control={form.control} 203 + name="verbose" 204 + render={({ field }) => ( 205 + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> 206 + <FormControl> 207 + <Checkbox 208 + checked={field.value} 209 + onCheckedChange={field.onChange} 210 + /> 211 + </FormControl> 212 + <div className="space-y-1 leading-none"> 213 + <FormLabel>Verbose</FormLabel> 214 + <FormDescription> 215 + Make the operation more talkative. 216 + </FormDescription> 217 + </div> 218 + </FormItem> 219 + )} 220 + /> 221 + <FormField 222 + control={form.control} 223 + name="insecure" 224 + render={({ field }) => ( 225 + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> 226 + <FormControl> 227 + <Checkbox 228 + checked={field.value} 229 + onCheckedChange={field.onChange} 230 + /> 231 + </FormControl> 232 + <div className="space-y-1 leading-none"> 233 + <FormLabel>Accept self-signed certificats</FormLabel> 234 + <FormDescription> 235 + Allow insecure server connections. 236 + </FormDescription> 237 + </div> 238 + </FormItem> 239 + )} 240 + /> 241 + <Separator /> 242 + <Textarea 243 + value={generateCurlCommand(debouncedformValues)} 244 + className="font-mono" 245 + aria-readonly 246 + readOnly 247 + /> 248 + <Button type="submit" className="w-full"> 249 + Copy to Clipboard 250 + </Button> 251 + </form> 252 + </Form> 253 + ); 254 + } 255 + 256 + function generateCurlCommand(form: z.infer<typeof formSchema>) { 257 + const { method, url, body, verbose, insecure, json, headers } = form; 258 + 259 + let curlCommand = "curl"; 260 + 261 + if (method) { 262 + curlCommand += ` -X ${method}`; 263 + } 264 + 265 + if (url) { 266 + curlCommand += ` "${url}" \\\n`; 267 + } else { 268 + // force a new line if there is no URL 269 + curlCommand += " \\\n"; 270 + } 271 + 272 + for (const header of headers) { 273 + const { key, value } = header; 274 + if (key && value) { 275 + curlCommand += ` -H "${key}: ${value}" \\\n`; 276 + } 277 + } 278 + 279 + if (json) { 280 + curlCommand += ' -H "Content-Type: application/json" \\\n'; 281 + } 282 + 283 + if (body?.trim()) { 284 + curlCommand += ` -d '${body.trim()}' \\\n`; 285 + } 286 + 287 + if (verbose) { 288 + curlCommand += " -v \\\n"; 289 + } 290 + 291 + if (insecure) { 292 + curlCommand += " -k \\\n"; 293 + } 294 + 295 + // Remove the trailing ` \` at the end 296 + return curlCommand.trim().slice(0, -2); 297 + }
+64
apps/web/src/app/play/curl/page.tsx
··· 1 + import { 2 + defaultMetadata, 3 + ogMetadata, 4 + twitterMetadata, 5 + } from "@/app/shared-metadata"; 6 + import { 7 + CardContainer, 8 + CardDescription, 9 + CardHeader, 10 + CardIcon, 11 + CardTitle, 12 + } from "@/components/marketing/card"; 13 + import type { Metadata } from "next"; 14 + import { CurlForm } from "./_components/curl-form"; 15 + 16 + const title = "cURL Builder"; 17 + const description = 18 + "An online curl command line builder. Generate curl commands to test your API endpoints."; 19 + 20 + export const metadata: Metadata = { 21 + ...defaultMetadata, 22 + title, 23 + description, 24 + twitter: { 25 + ...twitterMetadata, 26 + title, 27 + description, 28 + images: [`/api/og?title=${title}&description=${description}`], 29 + }, 30 + openGraph: { 31 + ...ogMetadata, 32 + title, 33 + description, 34 + images: [`/api/og?title=${title}&description=${description}`], 35 + }, 36 + }; 37 + 38 + export default function CurlBuilder() { 39 + return ( 40 + <CardContainer> 41 + <CardHeader> 42 + <CardIcon icon="terminal" /> 43 + <CardTitle>cURL Builder</CardTitle> 44 + <CardDescription className="max-w-md"> 45 + An online curl command line builder. Generate curl commands to test 46 + your API endpoints. 47 + </CardDescription> 48 + </CardHeader> 49 + <div className="mx-auto grid w-full max-w-xl gap-6"> 50 + <CurlForm 51 + defaultValues={{ 52 + method: "GET", 53 + url: "", 54 + body: "", 55 + verbose: false, 56 + insecure: false, 57 + json: false, 58 + headers: [], 59 + }} 60 + /> 61 + </div> 62 + </CardContainer> 63 + ); 64 + }
+29
apps/web/src/app/play/curl/search-params.ts
··· 1 + import { 2 + createSearchParamsCache, 3 + parseAsArrayOf, 4 + parseAsBoolean, 5 + parseAsJson, 6 + parseAsString, 7 + parseAsStringLiteral, 8 + } from "nuqs/server"; 9 + import { z } from "zod"; 10 + 11 + // REMINDER: not used, but could be useful for future reference 12 + export const searchParamsParsers = { 13 + method: parseAsStringLiteral(["GET", "POST", "PUT", "PATCH", "DELETE"]), 14 + url: parseAsString, 15 + body: parseAsString, 16 + verbose: parseAsBoolean, 17 + insecure: parseAsBoolean, 18 + json: parseAsBoolean, 19 + headers: parseAsArrayOf( 20 + parseAsJson( 21 + z.object({ 22 + key: z.string(), 23 + value: z.string(), 24 + }).parse, 25 + ), 26 + ), 27 + }; 28 + 29 + export const searchParamsCache = createSearchParamsCache(searchParamsParsers);
+7
apps/web/src/app/play/page.tsx
··· 6 6 Palette, 7 7 PanelTop, 8 8 Table, 9 + Terminal, 9 10 } from "lucide-react"; 10 11 import type { Metadata } from "next"; 11 12 ··· 58 59 "Get speed insights for your api, website from multiple regions. No account needed.", 59 60 icon: Gauge, 60 61 variant: "primary", 62 + }, 63 + { 64 + href: "/play/curl", 65 + title: "cURL Builder", 66 + description: "Easily generate curl commands to test your API endpoints.", 67 + icon: Terminal, 61 68 }, 62 69 { 63 70 href: "/public/monitors/1",
+1 -1
apps/web/src/app/shared-metadata.ts
··· 2 2 3 3 export const TITLE = "OpenStatus"; 4 4 export const DESCRIPTION = 5 - "A better way to monitor your API and your website. Don't let downtime or a slow response time ruin your user experience. Try OpenStatus the open-source synthetic monitoring platform for free!"; 5 + "A better way to monitor your API and your website. Don't let downtime or a slow response time ruin your user experience. Try the open-source synthetic monitoring platform for free!"; 6 6 7 7 export const defaultMetadata: Metadata = { 8 8 title: {
+2
apps/web/src/components/icons.tsx
··· 51 51 SunMedium, 52 52 Table, 53 53 Tag, 54 + Terminal, 54 55 Timer, 55 56 ToyBrick, 56 57 Trash, ··· 89 90 tag: Tag, 90 91 trash: Trash, 91 92 twitter: TwitterIcon, 93 + terminal: Terminal, 92 94 globe: Globe, 93 95 plug: Plug, 94 96 copy: Copy,
+1
apps/web/src/components/layout/marketing-footer.tsx
··· 50 50 <div className="order-3 flex flex-col gap-3 text-sm"> 51 51 <p className="font-semibold text-foreground">Tools</p> 52 52 <FooterLink href="/play/checker" label="Speed Checker" /> 53 + <FooterLink href="/play/curl" label="cURL Builder" /> 53 54 <FooterLink href="https://openstat.us" label="All Status Codes" /> 54 55 </div> 55 56 </div>
+10
apps/web/src/content/changelog/curl-builder-playground.mdx
··· 1 + --- 2 + title: cURL Builder 3 + description: An online curl command line builder. Generate curl commands to test your API endpoints. 4 + image: /assets/changelog/curl-builder-playground.png 5 + publishedAt: 2024-11-14 6 + --- 7 + 8 + An online curl command line builder. Generate curl commands to test your API endpoints. 9 + 10 + Go to the **[Playground](/play/curl)** and test it.