Feat/api reference curl examples (#720)

* curl reference initial

* add tooltips to curl command

* refactor copy component

* move copy to copy component

* formatting

* always include required fields

* add example values

* format svg

* fix property extraction

* explainer comment
This commit is contained in:
Philip Korsholm
2021-11-19 10:18:16 +01:00
committed by GitHub
parent 2d9879ea09
commit f162b4a2a1
11 changed files with 369 additions and 78 deletions

View File

@@ -38,10 +38,12 @@
"preact-render-to-string": "^5.1.19",
"prismjs": "^1.24.1",
"react": "^16.12.0",
"copy-to-clipboard": "^3.3.1",
"react-collapsible": "^2.8.1",
"react-dom": "^16.12.0",
"react-helmet": "^6.1.0",
"react-highlight.js": "^1.0.7",
"react-tooltip": "^4.2.10",
"react-intersection-observer": "^8.29.0",
"react-markdown": "^5.0.3",
"react-virtualized": "^9.22.3",

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg height="10" width="10" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<title>Clipboard</title>
<g>
<g>
<path d="M435.2,51.2H384v102.4H128V51.2H76.8c-15.36,0-25.6,10.24-25.6,25.6v409.6c0,15.36,10.24,25.6,25.6,25.6h358.4
c15.36,0,25.6-10.24,25.6-25.6V76.8C460.8,61.44,450.56,51.2,435.2,51.2z"/>
</g>
</g>
<g>
<g>
<path d="M307.2,0H204.8c-15.36,0-25.6,10.24-25.6,25.6v76.8h153.6V25.6C332.8,10.24,322.56,0,307.2,0z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 695 B

View File

@@ -0,0 +1,76 @@
import React, { useState } from "react"
import { Flex, Box, Text } from "theme-ui"
import Clipboard from '../icons/clipboard'
import ReactTooltip from "react-tooltip"
import styled from "@emotion/styled"
import copy from 'copy-to-clipboard'
const StyledTooltip = ({id, text}) => {
const StyledTooltip = styled(ReactTooltip)`
box-shadow: 0px 5px 15px 0px rgba(0, 0, 0, 0.08),
0px 0px 0px 1px rgba(136, 152, 170, 0.1),
0px 4px 4px 0px rgba(136, 152, 170, 0.1) !important;
padding: 8px 12px;
&:after {
margin-right: 4px;
}
&.show {
opacity: 1;
}
`
return(
<StyledTooltip
place="top"
backgroundColor='#FFF'
textColor='black'
effect="solid"
id={id}
sx={{ boxShadow: `0px 5px 15px 0px rgba(0, 0, 0, 0.08),
0px 0px 0px 1px rgba(136, 152, 170, 0.1),
0px 4px 4px 0px rgba(136, 152, 170, 0.1) !important`,
padding: `8px 12px`
}}
>
<Text>{text}</Text>
</StyledTooltip>
)
}
const CopyToClipboard = ({text, copyText, tooltipText}) => {
const [copied, setCopied] = useState(false)
const id = (Math.random()*1000000).toString()
const forceTooltipRemount = copied ? "content-1" : "content-2"
const onCopyClicked = () => {
copy(copyText || tooltipText, {format: 'text/plain'})
}
return (
<Box
mr={1}
onMouseLeave={() => {setCopied(false)}}
onClick={() => {
setCopied(true)
onCopyClicked()
}}
data-for={id}
data-tip={forceTooltipRemount}
key={forceTooltipRemount}
sx={{cursor: 'pointer'}}
>
{
text && (
<Text variant="small" mr={2} sx={{ fontWeight: "300" }}>
{text.toUpperCase()}
</Text>)
}
<Clipboard/>
{copied ?
<StyledTooltip id={id} text={"Copied!"} />
:
<StyledTooltip id={id} text={tooltipText} />
}
</Box>)
}
export default CopyToClipboard

View File

@@ -1,7 +1,8 @@
import React from "react"
import { Flex, Box, Text } from "theme-ui"
import CopyToClipboard from "../CopyToClipboard"
const CodeBox = ({ header, children }) => {
const CodeBox = ({ header, children, shell, content, allowCopy }) => {
return (
<Box
sx={{
@@ -23,9 +24,25 @@ const CodeBox = ({ header, children }) => {
borderRadius: "8px 8px 0 0",
}}
>
<Text variant="small" sx={{ fontWeight: "400" }}>
{header}
</Text>
<Flex
sx={{
height: "100%",
justifyContent: "space-between",
alignItems: "baseline",
}}
>
<Text variant="small" sx={{ fontWeight: "400" }}>
{header.toUpperCase()}
</Text>
{allowCopy ? (
<CopyToClipboard
copyText={content}
tooltipText={"Copy to clipboard"}
/>
) : (
<></>
)}
</Flex>
</Box>
<Box
sx={{

View File

@@ -22,7 +22,7 @@ const Content = ({ data, api }) => {
>
<main className="DocSearch-content">
{data.sections.map((s, i) => {
return <Section key={i} data={s} />
return <Section key={i} data={s} api={api} />
})}
</main>
</Box>

View File

@@ -5,9 +5,15 @@ import Prism from "prismjs"
import "prismjs/components/prism-json"
import CodeBox from "./code-box"
const JsonContainer = ({ json, header }) => {
const JsonContainer = ({ json, header, language, allowCopy }) => {
const jsonRef = useRef()
const codeClass = language
? language === "shell"
? "language-shell"
: "language-json"
: "language-json"
//INVESTIGATE: @theme-ui/prism might be a better solution
useEffect(() => {
if (jsonRef.current) {
@@ -19,9 +25,14 @@ const JsonContainer = ({ json, header }) => {
return (
<Box ref={jsonRef} sx={{ position: "sticky", top: "20px" }}>
<CodeBox header={header}>
<CodeBox
allowCopy={allowCopy}
content={json}
shell={language === "shell"}
header={header}
>
<pre>
<code className={"language-json"}>{json}</code>
<code className={codeClass}>{json}</code>
</pre>
</CodeBox>
</Box>

View File

@@ -10,18 +10,19 @@ import ResponsiveContainer from "./responsive-container"
import { formatMethodParams } from "../../utils/format-parameters"
import useInView from "../../hooks/use-in-view"
import NavigationContext from "../../context/navigation-context"
import { formatRoute } from "../../utils/format-route"
const Method = ({ data, section, pathname }) => {
const Method = ({ data, section, pathname, api }) => {
const { parameters, requestBody, description, method, summary } = data
const jsonResponse = data.responses[0].content?.[0].json
const { updateHash, updateMetadata } = useContext(NavigationContext)
const methodRef = useRef(null)
const [containerRef, isInView] = useInView({
root: null,
rootMargin: "0px 0px -80% 0px",
threshold: 0,
})
const formattedParameters = formatMethodParams({ parameters, requestBody })
useEffect(() => {
if (isInView) {
@@ -42,6 +43,129 @@ const Method = ({ data, section, pathname }) => {
}
}
const getExampleValues = (type, defaultExample) => {
switch (type) {
case "integer":
return 1000
case "boolean":
return false
case "object":
return {}
default:
return defaultExample
}
}
// extract required properties or a non-required property from a json object
// based on the extraction method "getPropertyFromObject"
const getPropertiesFromObject = (
requiredProperties,
properties,
obj,
res,
getPropertyFromObject
) => {
for (const element of requiredProperties) {
try {
res[element.property] = getPropertyFromObject(obj, element.property)
} catch (err) {}
}
// if (Object.keys(res) === requiredProperties.map((p) => p.property)) {
// return res
// }
for (const element of properties) {
try {
res[element.property] = getPropertyFromObject(obj, element.property)
break
} catch (err) {}
}
return res
}
const getCurlJson = (properties, prefix, bodyParameters) => {
if (!properties[0]) {
return
}
const jsonObject = JSON.parse(jsonResponse)
const pathParts = pathname.split("/")
const requiredProperties = bodyParameters.filter((p) => p.required)
let res = {}
// if the endpoint is for a relation i.e. /orders/:id/shipment drill down into the properties of the json object
if (pathParts.length > 3) {
const propertyIndex = pathParts[2].match(/{[A-Za-z_]+}/) ? 3 : 2
try {
const obj =
jsonObject[pathParts[propertyIndex].replace("-", "_")] ||
jsonObject[Object.keys(jsonObject)[0]][
pathParts[propertyIndex].replace("-", "_")
]
res = getPropertiesFromObject(
requiredProperties,
properties,
obj,
res,
(obj, property) =>
Array.isArray(obj)
? obj.find((o) => o[property])[property]
: obj[property]
)
} catch (err) {}
}
// if nothing was found drilling down look at the top level properties
if (JSON.stringify(res) === "{}") {
res = getPropertiesFromObject(
requiredProperties,
properties,
jsonObject,
res,
(jsonObject, property) =>
jsonObject[property] ||
jsonObject[Object.keys(jsonObject)[0]][property]
)
}
// Last resort, set the first property to an example
if (JSON.stringify(res) === "{}") {
res[properties[0].property] = getExampleValues(properties[0].type, `${prefix}_${properties[0].property}`)
}
// Add values to 'undefined' properties before returning due to JSON.stringify removing 'undefined' but not 'null'
return requiredProperties.reduce((prev, curr) => {
if(prev[curr.property] === undefined){
prev[curr.property] = getExampleValues(curr.type, `${prefix}_${curr.property}`)
}
return prev
}, res)
}
const getCurlCommand = (requestBody) => {
const body = JSON.stringify(
getCurlJson(
requestBody.properties,
`example_${section}`,
formattedParameters.body
)
)
return `curl -X ${data.method.toUpperCase()} https://medusa-url.com/${api}${formatRoute(
pathname
)} \\
--header "Authorization: Bearer <ACCESS TOKEN>" ${
data.method.toUpperCase() === "POST" && requestBody.properties?.length > 0
? `\\
--header "content-type: application/json" \\
--data '${body}'`
: ""
}`
}
return (
<Flex
py={"5vw"}
@@ -89,18 +213,27 @@ const Method = ({ data, section, pathname }) => {
<Markdown>{description}</Markdown>
</Text>
</Description>
<Box mt={4}>
<Parameters
params={formatMethodParams({ parameters, requestBody })}
/>
<Box mt={2}>
<Parameters params={formattedParameters} type={"Parameters"} />
</Box>
</Flex>
<Box className="code">
<JsonContainer
json={jsonResponse}
header={"RESPONSE"}
method={convertToKebabCase(summary)}
/>
<Box>
<JsonContainer
json={getCurlCommand(requestBody)}
header={"cURL Example"}
language={"shell"}
allowCopy={true}
method={convertToKebabCase(summary)}
/>
</Box>
<Box>
<JsonContainer
json={jsonResponse}
header={"RESPONSE"}
method={convertToKebabCase(summary)}
/>
</Box>
</Box>
</ResponsiveContainer>
</Flex>

View File

@@ -5,62 +5,77 @@ import NestedCollapsible from "./collapsible"
import Description from "./description"
const Parameters = ({ params, type }) => {
const getDescriptions = (title, items) => {
return (
<>
{items?.length > 0 && (
<>
<Text
sx={{ borderLeft: "2px solid gray", alignItems: "center" }}
my={3}
pl={2}
py={1}
>
{title === "attr" ? "Attributes" : title}
</Text>
{items.map((prop, i) => {
const nested = prop.nestedModel || prop.items?.properties || null
return (
<Box
py={2}
pl={2}
sx={{
borderTop: "hairline",
fontFamily: "monospace",
fontSize: "0",
}}
key={i}
>
<Flex sx={{ alignItems: "baseline", fontSize: "0" }}>
<Text mr={2}>{prop.property || prop.name}</Text>
<Text color={"gray"}>
{prop.type || prop.schema?.type || nested?.title}
</Text>
{prop.required ? (
<Text ml={1} variant="labels.required">
required
</Text>
) : null}
</Flex>
<Description>
<Text
sx={{
fontSize: "0",
lineHeight: "26px",
fontFamily: "body",
}}
>
<Markdown>{prop.description}</Markdown>
</Text>
</Description>
{nested?.properties && (
<NestedCollapsible
properties={nested.properties}
title={nested.title}
/>
)}
</Box>
)
})}
</>
)}
</>
)
}
return (
<Flex
sx={{
flexDirection: "column",
}}
>
<Text pb="2">{type === "attr" ? "Attributes" : "Parameters"}</Text>
{params.properties.length > 0 ? (
params.properties.map((prop, i) => {
const nested = prop.nestedModel || prop.items?.properties || null
return (
<Box
py={2}
sx={{
borderTop: "hairline",
fontFamily: "monospace",
fontSize: "0",
}}
key={i}
>
<Flex sx={{ alignItems: "baseline", fontSize: "0" }}>
<Text mr={2}>{prop.property || prop.name}</Text>
<Text color={"gray"}>
{prop.type || prop.schema?.type || nested?.title}
</Text>
{prop.required ? (
<Text ml={1} variant="labels.required">
required
</Text>
) : null}
</Flex>
<Description>
<Text
sx={{
fontSize: "0",
lineHeight: "26px",
fontFamily: "body",
}}
>
<Markdown>{prop.description}</Markdown>
</Text>
</Description>
{nested?.properties && (
<NestedCollapsible
properties={nested.properties}
title={nested.title}
/>
)}
</Box>
)
})
) : (
<Text sx={{ fontSize: "0", py: "3", fontFamily: "monospace" }}>
No parameters
</Text>
)}
{getDescriptions(type, params.properties)}
{getDescriptions("Request body", params.body)}
</Flex>
)
}

View File

@@ -12,7 +12,7 @@ import NavigationContext from "../../context/navigation-context"
import ChevronDown from "../icons/chevron-down"
import useInView from "../../hooks/use-in-view"
const Section = ({ data }) => {
const Section = ({ data, api }) => {
const { section } = data
const [isExpanded, setIsExpanded] = useState(false)
const { openSections, updateSection, updateMetadata } = useContext(
@@ -20,11 +20,11 @@ const Section = ({ data }) => {
)
const endpoints = section.paths
.map(p => {
.map((p) => {
let path = p.name
let ep = []
p.methods.forEach(m => {
p.methods.forEach((m) => {
ep.push({ method: m.method, endpoint: path })
})
@@ -184,6 +184,7 @@ const Section = ({ data }) => {
{p.methods.map((m, i) => {
return (
<Method
api={api}
key={i}
data={m}
section={convertToKebabCase(section.section_name)}

View File

@@ -0,0 +1,19 @@
import { Image } from "@theme-ui/components"
import React from "react"
import Logo from "../../assets/clipboard.svg"
const Clipboard = () => {
return (
<Image
src={Logo}
sx={{
height: "100%",
fill: "#000",
cursor: 'pointer'
}}
/>
)
}
export default Clipboard

View File

@@ -1,9 +1,10 @@
export const formatMethodParams = method => {
export const formatMethodParams = (method) => {
const { parameters, requestBody } = method
const params = []
const body = []
if (parameters && parameters.length > 0) {
parameters.map(p => {
parameters.map((p) => {
return params.push({
property: p.name,
description: p.description,
@@ -14,16 +15,16 @@ export const formatMethodParams = method => {
}
if (requestBody) {
const { required, properties } = requestBody
properties.map(p => {
return params.push({
properties.map((p) => {
return body.push({
property: p.property,
description: p.description,
required: required ? required.some(req => req === p.property) : false,
required: required ? required.some((req) => req === p.property) : false,
type: p.type,
nestedModel: p.nestedModel,
})
})
}
return { properties: params }
return { properties: params, body }
}