|
| 1 | +# Custom Generator |
| 2 | + |
| 3 | +You will probably want to extend or, at least, take a look at [BaseGenerator.js](https://github.com/api-platform/client-generator/blob/main/src/generators/BaseGenerator.js), since the library expects some methods to be available, as well as one of the included generator to make your own. |
| 4 | + |
| 5 | +## Usage |
| 6 | + |
| 7 | +```shell |
| 8 | +generate-api-platform-client -g "$(pwd)/path/to/custom/generator.js" -t "$(pwd)/path/to/templates" |
| 9 | +``` |
| 10 | + |
| 11 | +The `-g` argument can point to any resolvable node module which means it can be a package dependency of the current project as well as any js file. |
| 12 | + |
| 13 | +## Example |
| 14 | + |
| 15 | +Let's create a basic react generator with Create form as an example: |
| 16 | + |
| 17 | +### Generator |
| 18 | + |
| 19 | +```js |
| 20 | +// ./Generator.js |
| 21 | +import BaseGenerator from "@api-platform/client-generator/lib/generators/BaseGenerator"; |
| 22 | + |
| 23 | +export default class extends BaseGenerator { |
| 24 | + constructor(params) { |
| 25 | + super(params); |
| 26 | + |
| 27 | + this.registerTemplates("", [ |
| 28 | + "utils/dataAccess.js", |
| 29 | + "components/foo/Create.js", |
| 30 | + "routes/foo.js", |
| 31 | + ]); |
| 32 | + } |
| 33 | + |
| 34 | + help(resource) { |
| 35 | + const titleLc = resource.title.toLowerCase(); |
| 36 | + |
| 37 | + console.log( |
| 38 | + 'Code for the "%s" resource type has been generated!', |
| 39 | + resource.title |
| 40 | + ); |
| 41 | + console.log(` |
| 42 | +//import routes |
| 43 | +import ${titleLc}Routes from './routes/${titleLc}'; |
| 44 | +
|
| 45 | +// Add routes to <Switch> |
| 46 | +{ ${titleLc}Routes } |
| 47 | +`); |
| 48 | + } |
| 49 | + |
| 50 | + generate(api, resource, dir) { |
| 51 | + const lc = resource.title.toLowerCase(); |
| 52 | + const titleUcFirst = |
| 53 | + resource.title.charAt(0).toUpperCase() + resource.title.slice(1); |
| 54 | + |
| 55 | + const context = { |
| 56 | + title: resource.title, |
| 57 | + name: resource.name, |
| 58 | + lc, |
| 59 | + uc: resource.title.toUpperCase(), |
| 60 | + fields: resource.readableFields, |
| 61 | + formFields: this.buildFields(resource.writableFields), |
| 62 | + hydraPrefix: this.hydraPrefix, |
| 63 | + titleUcFirst, |
| 64 | + }; |
| 65 | + |
| 66 | + // Create directories |
| 67 | + // These directories may already exist |
| 68 | + [`${dir}/utils`, `${dir}/config`, `${dir}/routes`].forEach((dir) => |
| 69 | + this.createDir(dir, false) |
| 70 | + ); |
| 71 | + |
| 72 | + [`${dir}/components/${lc}`].forEach((dir) => this.createDir(dir)); |
| 73 | + |
| 74 | + ["components/%s/Create.js", "routes/%s.js"].forEach((pattern) => |
| 75 | + this.createFileFromPattern(pattern, dir, lc, context) |
| 76 | + ); |
| 77 | + |
| 78 | + // utils |
| 79 | + this.createFile( |
| 80 | + "utils/dataAccess.js", |
| 81 | + `${dir}/utils/dataAccess.js`, |
| 82 | + context, |
| 83 | + false |
| 84 | + ); |
| 85 | + |
| 86 | + this.createEntrypoint(api.entrypoint, `${dir}/config/entrypoint.js`); |
| 87 | + } |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +### `Create` component |
| 92 | + |
| 93 | +```js |
| 94 | +// template/components/Create.js |
| 95 | +import React from 'react'; |
| 96 | +import { Redirect } from 'react-router-dom'; |
| 97 | +import fetch from '../utils/dataAccess'; |
| 98 | + |
| 99 | +export default function Create() { |
| 100 | + const [isLoading, setLoading] = useState(false); |
| 101 | + const [error, setError] = useState(null); |
| 102 | + const [created, setCreated] = useState(null); |
| 103 | + |
| 104 | + const create = useCallback(async (e) => { |
| 105 | + setLoading(true) |
| 106 | + try { |
| 107 | + const values = Array.from(e.target.elements).reduce((vals, e) => { |
| 108 | + vals[e.id] = e.value; |
| 109 | + return vals |
| 110 | + }, {}) |
| 111 | + const response = await fetch('{{{name}}}', { method: 'POST', body: JSON.stringify(values) }); |
| 112 | + const retrieved = await response.json(); |
| 113 | + setCreated(retrieved); |
| 114 | + } catch (err) { |
| 115 | + setError(err); |
| 116 | + } finally { |
| 117 | + setLoading(false); |
| 118 | + } |
| 119 | + }, [setLoading, setError]) |
| 120 | + |
| 121 | + if (created) { |
| 122 | + return <Redirect to={`edit/${encodeURIComponent(created['@id'])}`} />; |
| 123 | + } |
| 124 | + |
| 125 | + return ( |
| 126 | + <div> |
| 127 | + <h1>New {{{title}}}</h1> |
| 128 | + |
| 129 | + {isLoading && ( |
| 130 | + <div className="alert alert-info" role="status"> |
| 131 | + Loading... |
| 132 | + </div> |
| 133 | + )} |
| 134 | + {error && ( |
| 135 | + <div className="alert alert-danger" role="alert"> |
| 136 | + <span className="fa fa-exclamation-triangle" aria-hidden="true" />{' '} |
| 137 | + {error} |
| 138 | + </div> |
| 139 | + )} |
| 140 | + |
| 141 | + <form onSubmit={create}> |
| 142 | +{{#each formFields}} |
| 143 | + <div className={`form-group`}> |
| 144 | + <label |
| 145 | + htmlFor={`{{{lc}}}_{{{name}}}`} |
| 146 | + className="form-control-label" |
| 147 | + > |
| 148 | + {data.input.name} |
| 149 | + </label> |
| 150 | + <input |
| 151 | + name="{{{name}}}" |
| 152 | + type="{{{type}}}"{{#if step}} |
| 153 | + step="{{{step}}}"{{/if}} |
| 154 | + placeholder="{{{description}}}"{{#if required}} |
| 155 | + required={true}{{/if}} |
| 156 | + id={`{{{lc}}}_{{{name}}}`} |
| 157 | + /> |
| 158 | + </div> |
| 159 | + |
| 160 | + <button type="submit" className="btn btn-success"> |
| 161 | + Submit |
| 162 | + </button> |
| 163 | + </form> |
| 164 | + </div> |
| 165 | + ); |
| 166 | +} |
| 167 | +``` |
| 168 | + |
| 169 | +### Utilities |
| 170 | + |
| 171 | +```js |
| 172 | +// template/entrypoint.js |
| 173 | +export const ENTRYPOINT = "{{{entrypoint}}}"; |
| 174 | +``` |
| 175 | + |
| 176 | +```js |
| 177 | +// template/routes/foo.js |
| 178 | +import React from "react"; |
| 179 | +import { Route } from "react-router-dom"; |
| 180 | +import { Create } from "../components/{{{lc}}}/"; |
| 181 | + |
| 182 | +export default [ |
| 183 | + <Route path="/{{{name}}}/create" component={Create} exact key="create" />, |
| 184 | +]; |
| 185 | +``` |
| 186 | + |
| 187 | +```js |
| 188 | +// template/utils/dataAccess.js |
| 189 | +import { ENTRYPOINT } from "../config/entrypoint"; |
| 190 | +import { SubmissionError } from "redux-form"; |
| 191 | +import get from "lodash/get"; |
| 192 | +import has from "lodash/has"; |
| 193 | +import mapValues from "lodash/mapValues"; |
| 194 | + |
| 195 | +const MIME_TYPE = "application/ld+json"; |
| 196 | + |
| 197 | +export function fetch(id, options = {}) { |
| 198 | + if ("undefined" === typeof options.headers) options.headers = new Headers(); |
| 199 | + if (null === options.headers.get("Accept")) |
| 200 | + options.headers.set("Accept", MIME_TYPE); |
| 201 | + |
| 202 | + if ( |
| 203 | + "undefined" !== options.body && |
| 204 | + !(options.body instanceof FormData) && |
| 205 | + null === options.headers.get("Content-Type") |
| 206 | + ) |
| 207 | + options.headers.set("Content-Type", MIME_TYPE); |
| 208 | + |
| 209 | + return global.fetch(new URL(id, ENTRYPOINT), options).then((response) => { |
| 210 | + if (response.ok) return response; |
| 211 | + |
| 212 | + return response.json().then( |
| 213 | + (json) => { |
| 214 | + const error = |
| 215 | + json["hydra:description"] || |
| 216 | + json["hydra:title"] || |
| 217 | + "An error occurred."; |
| 218 | + if (!json.violations) throw Error(error); |
| 219 | + |
| 220 | + let errors = { _error: error }; |
| 221 | + json.violations.forEach((violation) => |
| 222 | + errors[violation.propertyPath] |
| 223 | + ? (errors[violation.propertyPath] += |
| 224 | + "\n" + errors[violation.propertyPath]) |
| 225 | + : (errors[violation.propertyPath] = violation.message) |
| 226 | + ); |
| 227 | + |
| 228 | + throw new SubmissionError(errors); |
| 229 | + }, |
| 230 | + () => { |
| 231 | + throw new Error(response.statusText || "An error occurred."); |
| 232 | + } |
| 233 | + ); |
| 234 | + }); |
| 235 | +} |
| 236 | + |
| 237 | +export function mercureSubscribe(url, topics) { |
| 238 | + topics.forEach((topic) => |
| 239 | + url.searchParams.append("topic", new URL(topic, ENTRYPOINT)) |
| 240 | + ); |
| 241 | + |
| 242 | + return new EventSource(url.toString()); |
| 243 | +} |
| 244 | + |
| 245 | +export function normalize(data) { |
| 246 | + if (has(data, "hydra:member")) { |
| 247 | + // Normalize items in collections |
| 248 | + data["hydra:member"] = data["hydra:member"].map((item) => |
| 249 | + normalize(item) |
| 250 | + ); |
| 251 | + |
| 252 | + return data; |
| 253 | + } |
| 254 | + |
| 255 | + // Flatten nested documents |
| 256 | + return mapValues(data, (value) => |
| 257 | + Array.isArray(value) |
| 258 | + ? value.map((v) => normalize(v)) |
| 259 | + : value instanceof Object |
| 260 | + ? normalize(value) |
| 261 | + : get(value, "@id", value) |
| 262 | + ); |
| 263 | +} |
| 264 | + |
| 265 | +export function extractHubURL(response) { |
| 266 | + const linkHeader = response.headers.get("Link"); |
| 267 | + if (!linkHeader) return null; |
| 268 | + |
| 269 | + const matches = linkHeader.match( |
| 270 | + /<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/ |
| 271 | + ); |
| 272 | + |
| 273 | + return matches && matches[1] ? new URL(matches[1], ENTRYPOINT) : null; |
| 274 | +} |
| 275 | +``` |
| 276 | + |
| 277 | +Then we can use our generator: |
| 278 | + |
| 279 | +```shell |
| 280 | +generate-api-platform-client https://demo.api-platform.com out/ -g "$(pwd)/Generator.js" -t "$(pwd)/template" |
| 281 | +``` |
0 commit comments